@jmanuelcorral/openteam 0.1.30 → 0.1.32

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/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
2
4
 
3
5
  // src/cli.ts
4
6
  import { execFile, spawn } from "node:child_process";
@@ -301,1379 +303,1505 @@ function tokenFromQuery(query) {
301
303
  return value === null || value === undefined || value.length === 0 ? undefined : value;
302
304
  }
303
305
 
304
- // src/web/start.ts
305
- import { watch as fsWatch } from "node:fs";
306
-
307
- // src/web/paths.ts
308
- var DEFAULT_BACKLOG_PATH = ".opencode/openteam-backlog.md";
309
- var DEFAULT_DECISIONS_PATH = ".opencode/openteam-decisions.md";
310
-
311
- // src/web/server.ts
312
- import {
313
- createServer as createHttpServer
314
- } from "node:http";
315
-
316
- // src/console/render.ts
317
- var TIERS = [
318
- "trivial",
319
- "simple",
320
- "moderate",
321
- "hard"
322
- ];
323
- function escapeHtml(value) {
324
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
325
- }
326
- function usd(value) {
327
- return `$${value.toFixed(5)}`;
328
- }
329
- function pct(value) {
330
- return `${value.toFixed(2)}%`;
306
+ // src/telemetry/aggregate.ts
307
+ function emptyToolcallStats() {
308
+ return { count: 0, ok: 0, failed: 0, totalDurationMs: 0, byTool: {} };
331
309
  }
332
- function ms(value) {
333
- return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${Math.round(value)}ms`;
310
+ function emptySession(sessionID, ts) {
311
+ return {
312
+ sessionID,
313
+ firstTs: ts,
314
+ lastTs: ts,
315
+ routeCount: 0,
316
+ localCount: 0,
317
+ frontierCount: 0,
318
+ estimatedCostUSD: 0,
319
+ estimatedSavingsUSD: 0,
320
+ realCostUSD: 0,
321
+ messageCount: 0,
322
+ tokensIn: 0,
323
+ tokensOut: 0,
324
+ tokensReasoning: 0,
325
+ tokensCacheRead: 0,
326
+ tokensCacheWrite: 0,
327
+ toolcalls: emptyToolcallStats(),
328
+ models: [],
329
+ agents: []
330
+ };
334
331
  }
335
- function avgMs(stats) {
336
- return stats.count === 0 ? "—" : ms(stats.totalDurationMs / stats.count);
332
+ function pushUnique(list, value) {
333
+ if (value !== undefined && !list.includes(value)) {
334
+ list.push(value);
335
+ }
337
336
  }
338
- function agentModel(agent) {
339
- return agent.model === undefined ? "hereda default" : `${agent.model.providerID}/${agent.model.modelID}`;
337
+ function applyRoute(session, event) {
338
+ session.routeCount += 1;
339
+ if (event.routeKind === "local") {
340
+ session.localCount += 1;
341
+ } else {
342
+ session.frontierCount += 1;
343
+ }
344
+ session.estimatedCostUSD += event.estimatedCostUSD;
345
+ session.estimatedSavingsUSD += event.estimatedSavingsUSD;
346
+ pushUnique(session.models, `${event.selected.providerID}/${event.selected.modelID}`);
347
+ pushUnique(session.agents, event.agent);
340
348
  }
341
- function summaryPanel(cost) {
342
- return [
343
- '<section class="panel" id="panel-summary">',
344
- "<h2>Resumen de routing</h2>",
345
- '<div class="grid">',
346
- `<div class="stat"><span class="k">Decisiones</span><span class="v">${cost.count}</span></div>`,
347
- `<div class="stat"><span class="k">Local</span><span class="v">${cost.localCount}</span></div>`,
348
- `<div class="stat"><span class="k">Frontier</span><span class="v">${cost.frontierCount}</span></div>`,
349
- `<div class="stat"><span class="k">Coste real</span><span class="v">${usd(cost.totalEstimatedUSD)}</span></div>`,
350
- `<div class="stat"><span class="k">Baseline</span><span class="v">${usd(cost.totalBaselineUSD)}</span></div>`,
351
- `<div class="stat"><span class="k">Ahorro</span><span class="v good">${usd(cost.totalSavingsUSD)} (${pct(cost.savingsPct)})</span></div>`,
352
- `<div class="stat"><span class="k">Tokens in</span><span class="v">${cost.tokensIn}</span></div>`,
353
- `<div class="stat"><span class="k">Tokens out</span><span class="v">${cost.tokensOut}</span></div>`,
354
- "</div>",
355
- "</section>"
356
- ].join("");
349
+ function applyMessage(session, event) {
350
+ session.messageCount += 1;
351
+ session.realCostUSD += event.costUSD;
352
+ session.tokensIn += event.tokensIn;
353
+ session.tokensOut += event.tokensOut;
354
+ session.tokensReasoning += event.tokensReasoning;
355
+ session.tokensCacheRead += event.tokensCacheRead;
356
+ session.tokensCacheWrite += event.tokensCacheWrite;
357
+ pushUnique(session.models, `${event.providerID}/${event.modelID}`);
358
+ pushUnique(session.agents, event.agent);
357
359
  }
358
- function tierPanel(cost) {
359
- const rows = TIERS.map((tier) => {
360
- const summary = cost.byTier[tier];
361
- return `<tr><td>${tier}</td><td>${summary.count}</td><td>${usd(summary.estimatedUSD)}</td><td class="good">${usd(summary.savingsUSD)}</td></tr>`;
362
- }).join("");
363
- return [
364
- '<section class="panel" id="panel-tier">',
365
- "<h2>Por tier</h2>",
366
- "<table><thead><tr><th>Tier</th><th>Nº</th><th>Coste</th><th>Ahorro</th></tr></thead>",
367
- `<tbody>${rows}</tbody></table>`,
368
- "</section>"
369
- ].join("");
360
+ function applyToolUsage(stats, event) {
361
+ stats.count += 1;
362
+ stats.totalDurationMs += event.durationMs;
363
+ if (event.ok) {
364
+ stats.ok += 1;
365
+ } else {
366
+ stats.failed += 1;
367
+ }
370
368
  }
371
- function loopItemRow(item) {
372
- const box = item.done ? "☑" : "☐";
373
- const cls = item.done ? "done" : "open";
374
- const who = item.assignee === undefined ? "" : `<span class="who">@${escapeHtml(item.assignee)}</span> `;
375
- return `<li class="${cls}"><span class="box">${box}</span> ${who}${escapeHtml(item.text)}</li>`;
369
+ function applyToolcall(session, event) {
370
+ applyToolUsage(session.toolcalls, event);
371
+ const byTool = session.toolcalls.byTool[event.tool] ?? {
372
+ count: 0,
373
+ ok: 0,
374
+ failed: 0,
375
+ totalDurationMs: 0
376
+ };
377
+ applyToolUsage(byTool, event);
378
+ session.toolcalls.byTool[event.tool] = byTool;
379
+ pushUnique(session.agents, event.agent);
376
380
  }
377
- function loopPanel(loop) {
378
- if (loop === undefined) {
379
- return [
380
- '<section class="panel" id="panel-loop">',
381
- "<h2>Loop</h2>",
382
- '<p class="muted">Sin backlog activo (no hay <code>openteam-backlog.md</code>).</p>',
383
- "</section>"
384
- ].join("");
381
+ function applyEndpoint(session, event) {
382
+ if (session.endpoint !== undefined && session.endpoint.lastSeenTs >= event.ts) {
383
+ return;
385
384
  }
386
- const items = loop.items.map(loopItemRow).join("");
387
- const commit = loop.lastCommit === undefined ? "" : `<p class="muted">Último commit: <code>${escapeHtml(loop.lastCommit.hash)}</code> ${escapeHtml(loop.lastCommit.subject)}</p>`;
388
- return [
389
- '<section class="panel" id="panel-loop">',
390
- "<h2>Loop</h2>",
391
- `<div class="progress" role="progressbar" aria-valuenow="${loop.progressPct}" aria-valuemin="0" aria-valuemax="100"><div class="bar" style="width:${loop.progressPct}%"></div></div>`,
392
- `<p class="muted">${loop.done}/${loop.total} completados · ${loop.open} abiertos · ${loop.progressPct}%</p>`,
393
- `<ul class="items">${items}</ul>`,
394
- commit,
395
- "</section>"
396
- ].join("");
397
- }
398
- function teamPanel(team) {
399
- if (team.length === 0) {
400
- return [
401
- '<section class="panel" id="panel-team">',
402
- "<h2>Equipo</h2>",
403
- '<p class="muted">Sin agentes en <code>.opencode/agent/</code>.</p>',
404
- "</section>"
405
- ].join("");
385
+ const endpoint = {
386
+ url: event.url,
387
+ lastSeenTs: event.ts
388
+ };
389
+ if (event.directory !== undefined) {
390
+ endpoint.directory = event.directory;
406
391
  }
407
- const rows = team.map((agent) => `<tr><td>${escapeHtml(agent.name)}</td><td>${escapeHtml(agent.mode)}</td><td>${escapeHtml(agentModel(agent))}</td></tr>`).join("");
408
- return [
409
- '<section class="panel" id="panel-team">',
410
- "<h2>Equipo</h2>",
411
- "<table><thead><tr><th>Agente</th><th>Modo</th><th>LLM</th></tr></thead>",
412
- `<tbody>${rows}</tbody></table>`,
413
- "</section>"
414
- ].join("");
415
- }
416
- function routeRow(route) {
417
- return `<tr><td>${route.tier}</td><td class="${route.routeKind}">${route.routeKind}</td><td>${escapeHtml(route.model)}</td><td class="hash">${escapeHtml(route.promptHash)}</td><td>${usd(route.estimatedCostUSD)}</td><td class="good">${usd(route.estimatedSavingsUSD)}</td></tr>`;
418
- }
419
- function routesPanel(routes) {
420
- if (routes.length === 0) {
421
- return [
422
- '<section class="panel" id="panel-routes">',
423
- "<h2>Decisiones recientes</h2>",
424
- '<p class="muted">Sin decisiones de routing todavía.</p>',
425
- "</section>"
426
- ].join("");
392
+ if (event.worktree !== undefined) {
393
+ endpoint.worktree = event.worktree;
427
394
  }
428
- const rows = routes.map(routeRow).join("");
429
- return [
430
- '<section class="panel" id="panel-routes">',
431
- "<h2>Decisiones recientes</h2>",
432
- "<table><thead><tr><th>Tier</th><th>Ruta</th><th>Modelo</th><th>Prompt (hash)</th><th>Coste</th><th>Ahorro</th></tr></thead>",
433
- `<tbody>${rows}</tbody></table>`,
434
- "</section>"
435
- ].join("");
395
+ if (event.title !== undefined) {
396
+ endpoint.title = event.title;
397
+ }
398
+ session.endpoint = endpoint;
436
399
  }
437
- function activityRow(entry) {
438
- const who = entry.agent === undefined ? "" : `<span class="who">${escapeHtml(entry.agent)}</span> `;
439
- return `<li class="${entry.kind}"><span class="kind">${entry.kind}</span> ${who}${escapeHtml(entry.summary)}</li>`;
400
+ function mergeTool(target, source) {
401
+ target.count += source.count;
402
+ target.ok += source.ok;
403
+ target.failed += source.failed;
404
+ target.totalDurationMs += source.totalDurationMs;
440
405
  }
441
- function activityPanel(activity) {
442
- if (activity.length === 0) {
443
- return [
444
- '<section class="panel" id="panel-activity">',
445
- "<h2>Actividad</h2>",
446
- '<p class="muted">Sin actividad registrada.</p>',
447
- "</section>"
448
- ].join("");
406
+ function accumulateTotals(totals, session) {
407
+ totals.sessions += 1;
408
+ totals.routeCount += session.routeCount;
409
+ totals.localCount += session.localCount;
410
+ totals.frontierCount += session.frontierCount;
411
+ totals.estimatedCostUSD += session.estimatedCostUSD;
412
+ totals.estimatedSavingsUSD += session.estimatedSavingsUSD;
413
+ totals.realCostUSD += session.realCostUSD;
414
+ totals.messageCount += session.messageCount;
415
+ totals.tokensIn += session.tokensIn;
416
+ totals.tokensOut += session.tokensOut;
417
+ totals.tokensReasoning += session.tokensReasoning;
418
+ totals.tokensCacheRead += session.tokensCacheRead;
419
+ totals.tokensCacheWrite += session.tokensCacheWrite;
420
+ mergeTool(totals.toolcalls, session.toolcalls);
421
+ for (const [tool, usage] of Object.entries(session.toolcalls.byTool)) {
422
+ const target = totals.toolcalls.byTool[tool] ?? {
423
+ count: 0,
424
+ ok: 0,
425
+ failed: 0,
426
+ totalDurationMs: 0
427
+ };
428
+ mergeTool(target, usage);
429
+ totals.toolcalls.byTool[tool] = target;
449
430
  }
450
- const rows = activity.map(activityRow).join("");
451
- return [
452
- '<section class="panel" id="panel-activity">',
453
- "<h2>Actividad</h2>",
454
- `<ul class="timeline">${rows}</ul>`,
455
- "</section>"
456
- ].join("");
457
431
  }
458
- function totalsPanel(totals) {
459
- const t = totals.toolcalls;
460
- return [
461
- '<section class="panel" id="panel-totals">',
462
- "<h2>Telemetría real (todas las sesiones)</h2>",
463
- '<div class="grid">',
464
- `<div class="stat"><span class="k">Sesiones</span><span class="v">${totals.sessions}</span></div>`,
465
- `<div class="stat"><span class="k">Mensajes</span><span class="v">${totals.messageCount}</span></div>`,
466
- `<div class="stat"><span class="k">Coste real</span><span class="v">${usd(totals.realCostUSD)}</span></div>`,
467
- `<div class="stat"><span class="k">Ahorro est.</span><span class="v good">${usd(totals.estimatedSavingsUSD)}</span></div>`,
468
- `<div class="stat"><span class="k">Tokens in</span><span class="v">${totals.tokensIn}</span></div>`,
469
- `<div class="stat"><span class="k">Tokens out</span><span class="v">${totals.tokensOut}</span></div>`,
470
- `<div class="stat"><span class="k">Reasoning</span><span class="v">${totals.tokensReasoning}</span></div>`,
471
- `<div class="stat"><span class="k">Cache r/w</span><span class="v">${totals.tokensCacheRead}/${totals.tokensCacheWrite}</span></div>`,
472
- `<div class="stat"><span class="k">Toolcalls</span><span class="v">${t.count} <span class="good">${t.ok}✓</span> <span class="frontier">${t.failed}✗</span></span></div>`,
473
- `<div class="stat"><span class="k">Dur. media tool</span><span class="v">${avgMs(t)}</span></div>`,
474
- "</div>",
475
- "</section>"
476
- ].join("");
477
- }
478
- function sessionRow(session) {
479
- const models = session.models.map(escapeHtml).join("<br>") || "—";
480
- return [
481
- "<tr>",
482
- `<td class="hash">${escapeHtml(session.sessionID)}</td>`,
483
- `<td>${models}</td>`,
484
- `<td><span class="local">${session.localCount}</span>/<span class="frontier">${session.frontierCount}</span></td>`,
485
- `<td>${usd(session.realCostUSD)}</td>`,
486
- `<td>${session.tokensIn}/${session.tokensOut}</td>`,
487
- `<td>${session.toolcalls.count} (${avgMs(session.toolcalls)})</td>`,
488
- "</tr>"
489
- ].join("");
490
- }
491
- function sessionsPanel(sessions) {
492
- if (sessions.length === 0) {
493
- return [
494
- '<section class="panel" id="panel-sessions">',
495
- "<h2>Sesiones</h2>",
496
- '<p class="muted">Sin sesiones registradas todavía.</p>',
497
- "</section>"
498
- ].join("");
432
+ function toDecision(event) {
433
+ const view = {
434
+ ts: event.ts,
435
+ sessionID: event.sessionID,
436
+ summary: event.summary
437
+ };
438
+ if (event.agent !== undefined) {
439
+ view.agent = event.agent;
499
440
  }
500
- const rows = sessions.map(sessionRow).join("");
501
- return [
502
- '<section class="panel" id="panel-sessions">',
503
- "<h2>Sesiones</h2>",
504
- "<table><thead><tr><th>Sesión</th><th>Modelos</th><th>L/F</th><th>Coste</th><th>Tok in/out</th><th>Tools</th></tr></thead>",
505
- `<tbody>${rows}</tbody></table>`,
506
- "</section>"
507
- ].join("");
441
+ if (event.tags !== undefined) {
442
+ view.tags = [...event.tags];
443
+ }
444
+ return view;
508
445
  }
509
- function decisionRow(decision) {
510
- const who = decision.agent === undefined ? "" : `<span class="who">${escapeHtml(decision.agent)}</span> `;
511
- const tags = decision.tags === undefined || decision.tags.length === 0 ? "" : ` ${decision.tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join(" ")}`;
512
- return `<li class="decision"><span class="kind">decision</span> ${who}${escapeHtml(decision.summary)}${tags}</li>`;
446
+ function toMeeting(event) {
447
+ const view = {
448
+ ts: event.ts,
449
+ sessionID: event.sessionID,
450
+ batchID: event.batchID,
451
+ roles: [...event.roles]
452
+ };
453
+ if (event.purpose !== undefined) {
454
+ view.purpose = event.purpose;
455
+ }
456
+ if (event.decisionIDs !== undefined) {
457
+ view.decisionIDs = [...event.decisionIDs];
458
+ }
459
+ return view;
513
460
  }
514
- function decisionsPanel(decisions) {
515
- if (decisions.length === 0) {
516
- return [
517
- '<section class="panel" id="panel-decisions">',
518
- "<h2>Decisiones</h2>",
519
- '<p class="muted">Sin decisiones registradas (el scribe escribe en <code>openteam-decisions.md</code>).</p>',
520
- "</section>"
521
- ].join("");
461
+ function toActivity(event) {
462
+ const view = {
463
+ ts: event.ts,
464
+ sessionID: event.sessionID,
465
+ kind: event.kind,
466
+ summary: event.summary
467
+ };
468
+ if (event.agent !== undefined) {
469
+ view.agent = event.agent;
522
470
  }
523
- const rows = decisions.map(decisionRow).join("");
524
- return [
525
- '<section class="panel" id="panel-decisions">',
526
- "<h2>Decisiones</h2>",
527
- `<ul class="timeline">${rows}</ul>`,
528
- "</section>"
529
- ].join("");
471
+ return view;
530
472
  }
531
- function meetingRow(meeting) {
532
- const roles = meeting.roles.map(escapeHtml).join(", ");
533
- const purpose = meeting.purpose === undefined ? "" : `: ${escapeHtml(meeting.purpose)}`;
534
- return `<li class="agent"><span class="kind">reunión</span> <span class="who">${roles}</span>${purpose}</li>`;
473
+ function byTsDesc(list) {
474
+ return [...list].sort((a, b) => b.ts - a.ts);
535
475
  }
536
- function meetingsPanel(meetings) {
537
- if (meetings.length === 0) {
538
- return [
539
- '<section class="panel" id="panel-meetings">',
540
- "<h2>Reuniones</h2>",
541
- '<p class="muted">Sin reuniones multi-agente todavía.</p>',
542
- "</section>"
543
- ].join("");
476
+ function aggregateEvents(events) {
477
+ const sessions = new Map;
478
+ const decisions = [];
479
+ const meetings = [];
480
+ const activity = [];
481
+ const sessionFor = (sessionID, ts) => {
482
+ const existing = sessions.get(sessionID);
483
+ if (existing === undefined) {
484
+ const created = emptySession(sessionID, ts);
485
+ sessions.set(sessionID, created);
486
+ return created;
487
+ }
488
+ existing.firstTs = Math.min(existing.firstTs, ts);
489
+ existing.lastTs = Math.max(existing.lastTs, ts);
490
+ return existing;
491
+ };
492
+ for (const event of events) {
493
+ switch (event.type) {
494
+ case "route":
495
+ applyRoute(sessionFor(event.sessionID, event.ts), event);
496
+ break;
497
+ case "message":
498
+ applyMessage(sessionFor(event.sessionID, event.ts), event);
499
+ break;
500
+ case "toolcall":
501
+ applyToolcall(sessionFor(event.sessionID, event.ts), event);
502
+ break;
503
+ case "session-endpoint":
504
+ applyEndpoint(sessionFor(event.sessionID, event.ts), event);
505
+ break;
506
+ case "decision":
507
+ decisions.push(toDecision(event));
508
+ break;
509
+ case "meeting":
510
+ meetings.push(toMeeting(event));
511
+ break;
512
+ case "activity":
513
+ activity.push(toActivity(event));
514
+ break;
515
+ }
544
516
  }
545
- const rows = meetings.map(meetingRow).join("");
546
- return [
547
- '<section class="panel" id="panel-meetings">',
548
- "<h2>Reuniones</h2>",
549
- `<ul class="timeline">${rows}</ul>`,
550
- "</section>"
551
- ].join("");
517
+ const totals = {
518
+ sessions: 0,
519
+ routeCount: 0,
520
+ localCount: 0,
521
+ frontierCount: 0,
522
+ estimatedCostUSD: 0,
523
+ estimatedSavingsUSD: 0,
524
+ realCostUSD: 0,
525
+ messageCount: 0,
526
+ tokensIn: 0,
527
+ tokensOut: 0,
528
+ tokensReasoning: 0,
529
+ tokensCacheRead: 0,
530
+ tokensCacheWrite: 0,
531
+ toolcalls: emptyToolcallStats()
532
+ };
533
+ const summaries = [...sessions.values()].sort((a, b) => b.lastTs - a.lastTs);
534
+ for (const summary of summaries) {
535
+ summary.models.sort();
536
+ summary.agents.sort();
537
+ accumulateTotals(totals, summary);
538
+ }
539
+ return {
540
+ sessions: summaries,
541
+ totals,
542
+ decisions: byTsDesc(decisions),
543
+ meetings: byTsDesc(meetings),
544
+ activity: byTsDesc(activity)
545
+ };
552
546
  }
553
- var STATUS_LABELS = {
554
- running: "activa",
555
- idle: "en reposo",
556
- "waiting-input": "esperando input",
557
- ended: "finalizada"
558
- };
559
- function statusBadge(status) {
560
- return `<span class="badge status-${status}">${STATUS_LABELS[status]}</span>`;
547
+
548
+ // src/storage/path.ts
549
+ class StoragePathError extends Error {
550
+ constructor(message) {
551
+ super(message);
552
+ this.name = "StoragePathError";
553
+ }
561
554
  }
562
- function tabBar(sessions) {
563
- const allTab = '<button type="button" class="tab" data-tab="all">Todas</button>';
564
- const tabs = sessions.map((tab) => {
565
- const id = escapeHtml(tab.sessionID);
566
- const short = escapeHtml(shortSessionID(tab.sessionID));
567
- return `<button type="button" class="tab" data-tab="${id}"><span class="dot status-${tab.status}"></span>${short}</button>`;
568
- }).join("");
569
- return `<nav class="tabs" id="tabbar">${allTab}${tabs}</nav>`;
555
+ function normalizeVirtualPath(input) {
556
+ const segments = [];
557
+ for (const rawSegment of input.split(/[\\/]+/)) {
558
+ if (rawSegment === "" || rawSegment === ".") {
559
+ continue;
560
+ }
561
+ if (rawSegment === "..") {
562
+ if (segments.length === 0) {
563
+ throw new StoragePathError(`la ruta "${input}" escapa por encima de la raíz`);
564
+ }
565
+ segments.pop();
566
+ continue;
567
+ }
568
+ segments.push(rawSegment);
569
+ }
570
+ return segments.join("/");
570
571
  }
571
- function shortSessionID(sessionID) {
572
- return sessionID.length <= 12 ? sessionID : `${sessionID.slice(0, 12)}…`;
572
+ function joinVirtualPath(...segments) {
573
+ return normalizeVirtualPath(segments.join("/"));
573
574
  }
574
- function sessionHeaderPanel(tab) {
575
- const endpoint = tab.endpoint === undefined ? '<span class="muted">endpoint no anunciado</span>' : `<code>${escapeHtml(tab.endpoint.url)}</code>`;
576
- const dir = tab.endpoint?.directory === undefined ? "" : `<div class="stat"><span class="k">Directorio</span><span class="v mono">${escapeHtml(tab.endpoint.directory)}</span></div>`;
577
- const title = tab.endpoint?.title === undefined ? "" : `<div class="stat"><span class="k">Título</span><span class="v">${escapeHtml(tab.endpoint.title)}</span></div>`;
578
- const s = tab.summary;
579
- return [
580
- '<section class="panel">',
581
- `<h2>Sesión ${escapeHtml(shortSessionID(tab.sessionID))} ${statusBadge(tab.status)}</h2>`,
582
- '<div class="grid">',
583
- `<div class="stat"><span class="k">Endpoint</span><span class="v">${endpoint}</span></div>`,
584
- dir,
585
- title,
586
- `<div class="stat"><span class="k">Coste real</span><span class="v">${usd(s.realCostUSD)}</span></div>`,
587
- `<div class="stat"><span class="k">Tokens in/out</span><span class="v">${s.tokensIn}/${s.tokensOut}</span></div>`,
588
- `<div class="stat"><span class="k">Local/Frontier</span><span class="v"><span class="local">${s.localCount}</span>/<span class="frontier">${s.frontierCount}</span></span></div>`,
589
- `<div class="stat"><span class="k">Toolcalls</span><span class="v">${s.toolcalls.count} (${avgMs(s.toolcalls)})</span></div>`,
590
- "</div>",
591
- "</section>"
592
- ].join("");
575
+ function splitVirtualPath(path) {
576
+ const normalized = normalizeVirtualPath(path);
577
+ return normalized === "" ? [] : normalized.split("/");
593
578
  }
594
- function terminalPanel(tab) {
595
- return [
596
- '<section class="panel term">',
597
- "<h2>Terminal (sesión)</h2>",
598
- '<pre class="termlog" aria-live="polite"></pre>',
599
- '<form class="terminput" autocomplete="off">',
600
- `<input type="text" name="text" placeholder="Enviar prompt a ${escapeHtml(shortSessionID(tab.sessionID))}…" aria-label="Prompt a la sesión">`,
601
- '<button type="submit">Enviar</button>',
602
- "</form>",
603
- "</section>"
604
- ].join("");
605
- }
606
- function ptyPanel(tab) {
607
- return [
608
- `<section class="panel pty" data-pty-session="${escapeHtml(tab.sessionID)}">`,
609
- "<h2>Terminal (PTY)</h2>",
610
- '<pre class="ptylog" aria-live="polite"></pre>',
611
- '<form class="ptyinput" autocomplete="off" hidden>',
612
- '<input type="text" name="cmd" placeholder="Comando…" aria-label="Comando de la terminal">',
613
- '<button type="submit">Ejecutar</button>',
614
- "</form>",
615
- '<button type="button" class="pty-start">Abrir shell</button>',
616
- "</section>"
617
- ].join("");
618
- }
619
- function sessionView(tab, terminalEnabled, ptyEnabled) {
620
- const body = [
621
- sessionHeaderPanel(tab),
622
- terminalEnabled ? terminalPanel(tab) : "",
623
- terminalEnabled && ptyEnabled ? ptyPanel(tab) : "",
624
- routesPanel(tab.recentRoutes),
625
- decisionsPanel(tab.decisions),
626
- meetingsPanel(tab.meetings),
627
- activityPanel(tab.activity)
628
- ].join("");
629
- return `<div class="view" data-view="${escapeHtml(tab.sessionID)}" hidden>${body}</div>`;
579
+
580
+ // src/telemetry/events.ts
581
+ import { z as z4 } from "zod";
582
+
583
+ // src/capabilities/types.ts
584
+ import { z as z2 } from "zod";
585
+ var CapabilityTierSchema = z2.union([
586
+ z2.literal(0),
587
+ z2.literal(1),
588
+ z2.literal(2),
589
+ z2.literal(3),
590
+ z2.literal(4),
591
+ z2.literal(5)
592
+ ]);
593
+ var ComplexityTierSchema = z2.enum([
594
+ "trivial",
595
+ "simple",
596
+ "moderate",
597
+ "hard"
598
+ ]);
599
+ var ModelCapabilityProfileSchema = z2.object({
600
+ ref: z2.object({
601
+ providerID: z2.string().min(1),
602
+ modelID: z2.string().min(1)
603
+ }),
604
+ kind: z2.enum(["local", "frontier", "router"]),
605
+ contextWindow: z2.number().int().positive(),
606
+ maxOutputTokens: z2.number().int().positive(),
607
+ supportsToolCalling: z2.boolean(),
608
+ supportsVision: z2.boolean(),
609
+ reasoningTier: CapabilityTierSchema,
610
+ codeQualityTier: CapabilityTierSchema,
611
+ costPer1M: z2.object({
612
+ inputUSD: z2.number().min(0),
613
+ outputUSD: z2.number().min(0)
614
+ }),
615
+ availability: z2.enum(["available", "degraded", "unavailable"])
616
+ });
617
+
618
+ // src/config/schema.ts
619
+ import { z as z3 } from "zod";
620
+ var ModelRefSchema = z3.object({
621
+ providerID: z3.string().min(1),
622
+ modelID: z3.string().min(1)
623
+ });
624
+ var RouterModeSchema = z3.enum(["economy", "balanced", "quality"]);
625
+ var PrivacyModeSchema = z3.enum([
626
+ "forceLocalOnSensitive",
627
+ "consentBeforeFrontier",
628
+ "off"
629
+ ]);
630
+ var BaselineModeSchema = z3.enum(["auto", "pinned"]);
631
+ var ConsoleHostSchema = z3.enum(["127.0.0.1", "localhost"]);
632
+ var ConsoleConfigSchema = z3.object({
633
+ host: ConsoleHostSchema.default("127.0.0.1"),
634
+ port: z3.number().int().min(1024).max(65535).default(4599),
635
+ autoPortFallback: z3.boolean().default(true),
636
+ refreshMs: z3.number().int().min(250).default(2000),
637
+ recentRoutes: z3.number().int().positive().default(50),
638
+ openBrowser: z3.boolean().default(false),
639
+ remoteStorage: z3.boolean().default(false),
640
+ terminal: z3.object({
641
+ enabled: z3.boolean().default(true),
642
+ pty: z3.boolean().optional()
643
+ }).default({ enabled: true })
644
+ }).default({
645
+ host: "127.0.0.1",
646
+ port: 4599,
647
+ autoPortFallback: true,
648
+ refreshMs: 2000,
649
+ recentRoutes: 50,
650
+ openBrowser: false,
651
+ remoteStorage: false,
652
+ terminal: { enabled: true }
653
+ });
654
+ var StorageConfigSchema = z3.object({
655
+ sqliteIndex: z3.object({
656
+ enabled: z3.boolean().default(false),
657
+ path: z3.string().min(1).default(".opencode/openteam/index.sqlite")
658
+ }).default({ enabled: false, path: ".opencode/openteam/index.sqlite" })
659
+ }).default({
660
+ sqliteIndex: { enabled: false, path: ".opencode/openteam/index.sqlite" }
661
+ });
662
+ var defaultLocalModel = {
663
+ providerID: "ollama",
664
+ modelID: "qwen3:8b"
665
+ };
666
+ var defaultFrontierModel = {
667
+ providerID: "anthropic",
668
+ modelID: "claude-sonnet-4-5"
669
+ };
670
+ var LocalRuntimeSchema = z3.object({
671
+ id: z3.enum(["ollama", "lmstudio", "foundry-local"]),
672
+ enabled: z3.boolean().default(true),
673
+ baseURL: z3.string().url().optional(),
674
+ discovery: z3.enum(["cli", "sdk", "manual"]).optional(),
675
+ defaultModel: ModelRefSchema
676
+ });
677
+ var OpenTeamConfigObjectSchema = z3.object({
678
+ baseline: z3.object({
679
+ mode: BaselineModeSchema.default("auto"),
680
+ pinnedModel: ModelRefSchema.nullable().default(null),
681
+ hardDefault: ModelRefSchema.default(defaultFrontierModel)
682
+ }).default({
683
+ mode: "auto",
684
+ pinnedModel: null,
685
+ hardDefault: defaultFrontierModel
686
+ }),
687
+ router: z3.object({
688
+ mode: RouterModeSchema.default("balanced"),
689
+ localDefault: ModelRefSchema.default(defaultLocalModel),
690
+ trivialPromptMaxChars: z3.number().int().positive().default(280),
691
+ frontierPromptMinChars: z3.number().int().positive().default(2000)
692
+ }).default({
693
+ mode: "balanced",
694
+ localDefault: defaultLocalModel,
695
+ trivialPromptMaxChars: 280,
696
+ frontierPromptMinChars: 2000
697
+ }),
698
+ local: z3.object({
699
+ runtimes: z3.array(LocalRuntimeSchema).min(1).default([
700
+ {
701
+ id: "ollama",
702
+ enabled: true,
703
+ baseURL: "http://localhost:11434/v1",
704
+ defaultModel: { providerID: "ollama", modelID: "qwen3:8b" }
705
+ }
706
+ ])
707
+ }).default({
708
+ runtimes: [
709
+ {
710
+ id: "ollama",
711
+ enabled: true,
712
+ baseURL: "http://localhost:11434/v1",
713
+ defaultModel: defaultLocalModel
714
+ }
715
+ ]
716
+ }),
717
+ budgets: z3.object({
718
+ sessionUSD: z3.number().positive().optional(),
719
+ monthlyUSD: z3.number().positive().optional(),
720
+ frontierTokensPerSession: z3.number().int().positive().optional(),
721
+ hardStopOnBudgetExhaustion: z3.boolean().default(false)
722
+ }).default({ hardStopOnBudgetExhaustion: false }),
723
+ privacyMode: PrivacyModeSchema.default("forceLocalOnSensitive"),
724
+ console: ConsoleConfigSchema,
725
+ storage: StorageConfigSchema
726
+ });
727
+ var OpenTeamConfigSchema = OpenTeamConfigObjectSchema;
728
+
729
+ // src/telemetry/events.ts
730
+ var EVENT_SCHEMA_VERSION = 1;
731
+ var EventBaseSchema = z4.object({
732
+ v: z4.literal(EVENT_SCHEMA_VERSION),
733
+ ts: z4.number().finite(),
734
+ sessionID: z4.string().min(1)
735
+ });
736
+ var RouteEventSchema = EventBaseSchema.extend({
737
+ type: z4.literal("route"),
738
+ promptHash: z4.string().min(1),
739
+ promptChars: z4.number().int().min(0),
740
+ tier: ComplexityTierSchema,
741
+ routeKind: z4.enum(["local", "frontier"]),
742
+ selected: ModelRefSchema,
743
+ rationale: z4.string(),
744
+ estimatedCostUSD: z4.number().finite().min(0),
745
+ baselineCostUSD: z4.number().finite().min(0),
746
+ estimatedSavingsUSD: z4.number().finite(),
747
+ budgetAction: z4.string().min(1),
748
+ tokensIn: z4.number().int().min(0).optional(),
749
+ tokensOut: z4.number().int().min(0).optional(),
750
+ decisionID: z4.string().min(1).optional(),
751
+ agent: z4.string().min(1).optional(),
752
+ success: z4.boolean().optional(),
753
+ batchID: z4.string().min(1).optional(),
754
+ failureReason: z4.string().min(1).optional(),
755
+ failureStage: z4.string().min(1).optional()
756
+ });
757
+ var MessageEventSchema = EventBaseSchema.extend({
758
+ type: z4.literal("message"),
759
+ providerID: z4.string().min(1),
760
+ modelID: z4.string().min(1),
761
+ agent: z4.string().min(1).optional(),
762
+ mode: z4.string().min(1).optional(),
763
+ messageID: z4.string().min(1).optional(),
764
+ costUSD: z4.number().finite().min(0),
765
+ tokensIn: z4.number().int().min(0),
766
+ tokensOut: z4.number().int().min(0),
767
+ tokensReasoning: z4.number().int().min(0),
768
+ tokensCacheRead: z4.number().int().min(0),
769
+ tokensCacheWrite: z4.number().int().min(0),
770
+ durationMs: z4.number().int().min(0)
771
+ });
772
+ var ToolcallEventSchema = EventBaseSchema.extend({
773
+ type: z4.literal("toolcall"),
774
+ tool: z4.string().min(1),
775
+ callID: z4.string().min(1),
776
+ agent: z4.string().min(1).optional(),
777
+ durationMs: z4.number().int().min(0),
778
+ ok: z4.boolean(),
779
+ title: z4.string().optional()
780
+ });
781
+ var MeetingEventSchema = EventBaseSchema.extend({
782
+ type: z4.literal("meeting"),
783
+ batchID: z4.string().min(1),
784
+ purpose: z4.string().optional(),
785
+ roles: z4.array(z4.string().min(1)),
786
+ decisionIDs: z4.array(z4.string().min(1)).optional()
787
+ });
788
+ var DecisionEventSchema = EventBaseSchema.extend({
789
+ type: z4.literal("decision"),
790
+ agent: z4.string().min(1).optional(),
791
+ summary: z4.string().min(1),
792
+ tags: z4.array(z4.string().min(1)).optional()
793
+ });
794
+ var ActivityEventSchema = EventBaseSchema.extend({
795
+ type: z4.literal("activity"),
796
+ kind: z4.enum(["route", "agent", "decision", "commit"]),
797
+ agent: z4.string().min(1).optional(),
798
+ summary: z4.string().min(1)
799
+ });
800
+ var SessionEndpointEventSchema = EventBaseSchema.extend({
801
+ type: z4.literal("session-endpoint"),
802
+ url: z4.string().url(),
803
+ directory: z4.string().min(1).optional(),
804
+ worktree: z4.string().min(1).optional(),
805
+ title: z4.string().min(1).optional()
806
+ });
807
+ var OpenTeamEventSchema = z4.discriminatedUnion("type", [
808
+ RouteEventSchema,
809
+ MessageEventSchema,
810
+ ToolcallEventSchema,
811
+ MeetingEventSchema,
812
+ DecisionEventSchema,
813
+ ActivityEventSchema,
814
+ SessionEndpointEventSchema
815
+ ]);
816
+
817
+ // src/storage/fsStorageProvider.ts
818
+ import {
819
+ appendFile,
820
+ mkdir,
821
+ readdir,
822
+ readFile,
823
+ rm,
824
+ stat,
825
+ writeFile
826
+ } from "node:fs/promises";
827
+ import { dirname, join } from "node:path";
828
+ function isMissing(error) {
829
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
630
830
  }
631
- var STYLE = `
632
- :root{color-scheme:dark;--bg:#0f1115;--panel:#171a21;--fg:#e6e8eb;--muted:#8b929c;--good:#3fb950;--local:#58a6ff;--frontier:#d29922;--line:#262b34}
633
- *{box-sizing:border-box}
634
- body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 ui-sans-serif,system-ui,Segoe UI,Roboto,sans-serif}
635
- header{padding:16px 24px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}
636
- header h1{font-size:18px;margin:0}
637
- header .meta{color:var(--muted);font-size:12px}
638
- main{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;padding:24px}
639
- .panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px}
640
- .panel h2{font-size:14px;margin:0 0 12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
641
- .grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
642
- .stat{display:flex;flex-direction:column;gap:2px}
643
- .stat .k{color:var(--muted);font-size:12px}
644
- .stat .v{font-size:18px;font-weight:600}
645
- .good{color:var(--good)}
646
- .local{color:var(--local)}
647
- .frontier{color:var(--frontier)}
648
- .muted{color:var(--muted)}
649
- table{width:100%;border-collapse:collapse;font-size:13px}
650
- th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
651
- th{color:var(--muted);font-weight:600}
652
- .hash{font-family:ui-monospace,Consolas,monospace;color:var(--muted)}
653
- .progress{height:10px;background:#0b0d11;border-radius:6px;overflow:hidden;border:1px solid var(--line)}
654
- .progress .bar{height:100%;background:var(--good)}
655
- ul.items,ul.timeline{list-style:none;margin:8px 0 0;padding:0;max-height:320px;overflow:auto}
656
- ul.items li,ul.timeline li{padding:4px 0;border-bottom:1px solid var(--line)}
657
- ul.items li.done{color:var(--muted)}
658
- ul.items .box{font-family:monospace}
659
- .who{color:var(--local)}
660
- .tag{color:var(--frontier);font-size:12px}
661
- .kind{display:inline-block;min-width:66px;color:var(--muted);font-size:12px}
662
- code{font-family:ui-monospace,Consolas,monospace;background:#0b0d11;padding:1px 5px;border-radius:4px}
663
- .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px}
664
- .tabs{display:flex;gap:6px;padding:12px 24px 0;flex-wrap:wrap;border-bottom:1px solid var(--line)}
665
- .tabs .tab{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-bottom:none;border-radius:8px 8px 0 0;padding:8px 14px;cursor:pointer;font:inherit;display:flex;align-items:center;gap:6px}
666
- .tabs .tab.active{background:var(--bg);border-color:var(--local);color:var(--fg)}
667
- .dot{width:8px;height:8px;border-radius:50%;display:inline-block;background:var(--muted)}
668
- .badge{font-size:11px;padding:1px 8px;border-radius:10px;border:1px solid var(--line);color:var(--muted);vertical-align:middle}
669
- .status-running{background:var(--good)}
670
- .badge.status-running{color:var(--good);border-color:var(--good)}
671
- .status-idle{background:var(--muted)}
672
- .badge.status-idle{color:var(--muted)}
673
- .status-waiting-input{background:var(--frontier)}
674
- .badge.status-waiting-input{color:var(--frontier);border-color:var(--frontier)}
675
- .status-ended{background:#6e7681}
676
- .badge.status-ended{color:#6e7681}
677
- .term .termlog{background:#0b0d11;border:1px solid var(--line);border-radius:8px;padding:10px;max-height:340px;overflow:auto;font-family:ui-monospace,Consolas,monospace;font-size:12px;white-space:pre-wrap}
678
- .term .tl{padding:1px 0}
679
- .term .tl.input{color:var(--local)}
680
- .term .tl.tool{color:var(--frontier)}
681
- .term .tl.status{color:var(--muted)}
682
- .term .tl.error{color:#f85149}
683
- .term .tl.permission{color:var(--frontier);font-weight:600}
684
- .term form.terminput{display:flex;gap:8px;margin-top:10px}
685
- .term form.terminput input{flex:1;background:#0b0d11;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:inherit}
686
- .term form.terminput button{background:var(--local);color:#04122b;border:none;border-radius:8px;padding:8px 16px;cursor:pointer;font:inherit;font-weight:600}
687
- .pty .ptylog{background:#05070a;border:1px solid var(--line);border-radius:8px;padding:10px;max-height:340px;overflow:auto;font-family:ui-monospace,Consolas,monospace;font-size:12px;white-space:pre-wrap;color:#c8d1dc}
688
- .pty form.ptyinput{display:flex;gap:8px;margin-top:10px}
689
- .pty form.ptyinput input{flex:1;background:#05070a;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:inherit}
690
- .pty form.ptyinput button,.pty .pty-start{background:var(--frontier);color:#1a1200;border:none;border-radius:8px;padding:8px 16px;cursor:pointer;font:inherit;font-weight:600;margin-top:8px}
691
- .pty .pty-start:disabled{opacity:.5;cursor:default}
692
- `;
693
- var CLIENT_JS = `
694
- (function(){
695
- function reloadIfChanged(prev){
696
- fetch('/api/state').then(function(r){return r.json()}).then(function(s){
697
- if(s.generatedAt!==prev){location.reload()}
698
- }).catch(function(){});
699
- }
700
- var current=document.documentElement.getAttribute('data-generated')||'';
701
- if('EventSource' in window){
702
- try{
703
- var es=new EventSource('/events');
704
- es.addEventListener('state',function(){location.reload()});
705
- es.onerror=function(){/* fallback below */};
706
- }catch(e){/* ignore */}
831
+ function createFsStorageProvider(root) {
832
+ function toDisk(path) {
833
+ return join(root, ...splitVirtualPath(path));
707
834
  }
708
- var ms=parseInt(document.documentElement.getAttribute('data-refresh')||'2000',10);
709
- setInterval(function(){reloadIfChanged(current)},isNaN(ms)?2000:Math.max(250,ms));
835
+ return {
836
+ async read(path) {
837
+ try {
838
+ return await readFile(toDisk(path), "utf8");
839
+ } catch (error) {
840
+ if (isMissing(error)) {
841
+ return;
842
+ }
843
+ throw error;
844
+ }
845
+ },
846
+ async write(path, content) {
847
+ const disk = toDisk(path);
848
+ await mkdir(dirname(disk), { recursive: true });
849
+ await writeFile(disk, content, "utf8");
850
+ },
851
+ async append(path, content) {
852
+ const disk = toDisk(path);
853
+ await mkdir(dirname(disk), { recursive: true });
854
+ await appendFile(disk, content, "utf8");
855
+ },
856
+ async exists(path) {
857
+ try {
858
+ await stat(toDisk(path));
859
+ return true;
860
+ } catch (error) {
861
+ if (isMissing(error)) {
862
+ return false;
863
+ }
864
+ throw error;
865
+ }
866
+ },
867
+ async list(dir) {
868
+ try {
869
+ const names = await readdir(toDisk(dir));
870
+ return names.sort();
871
+ } catch (error) {
872
+ if (isMissing(error)) {
873
+ return [];
874
+ }
875
+ throw error;
876
+ }
877
+ },
878
+ async isDirectory(path) {
879
+ try {
880
+ return (await stat(toDisk(path))).isDirectory();
881
+ } catch (error) {
882
+ if (isMissing(error)) {
883
+ return false;
884
+ }
885
+ throw error;
886
+ }
887
+ },
888
+ async mkdir(dir) {
889
+ await mkdir(toDisk(dir), { recursive: true });
890
+ },
891
+ async delete(path) {
892
+ await rm(toDisk(path), { force: true });
893
+ },
894
+ async stat(path) {
895
+ try {
896
+ const stats = await stat(toDisk(path));
897
+ return {
898
+ size: stats.size,
899
+ mtimeMs: stats.mtimeMs,
900
+ isDirectory: stats.isDirectory()
901
+ };
902
+ } catch (error) {
903
+ if (isMissing(error)) {
904
+ return;
905
+ }
906
+ throw error;
907
+ }
908
+ }
909
+ };
910
+ }
710
911
 
711
- function selectTab(name){
712
- var tabs=document.querySelectorAll('#tabbar .tab');
713
- for(var i=0;i<tabs.length;i++){tabs[i].classList.toggle('active',tabs[i].getAttribute('data-tab')===name)}
714
- var views=document.querySelectorAll('main .view');
715
- var matched=false;
716
- for(var j=0;j<views.length;j++){
717
- var show=views[j].getAttribute('data-view')===name;
718
- views[j].hidden=!show;
719
- if(show){matched=true;if(name!=='all'){connectTerminal(views[j],name)}}
912
+ // src/telemetry/types.ts
913
+ import { z as z5 } from "zod";
914
+ var CostRecordSchema = z5.object({
915
+ ts: z5.number().finite(),
916
+ sessionID: z5.string().min(1).optional(),
917
+ promptHash: z5.string().min(1),
918
+ promptChars: z5.number().int().min(0),
919
+ tier: ComplexityTierSchema,
920
+ routeKind: z5.enum(["local", "frontier"]),
921
+ selected: ModelRefSchema,
922
+ rationale: z5.string(),
923
+ estimatedCostUSD: z5.number().finite().min(0),
924
+ baselineCostUSD: z5.number().finite().min(0),
925
+ estimatedSavingsUSD: z5.number().finite(),
926
+ budgetAction: z5.string().min(1),
927
+ tokensIn: z5.number().int().min(0).optional(),
928
+ tokensOut: z5.number().int().min(0).optional()
929
+ });
930
+
931
+ // src/telemetry/read.ts
932
+ var DEFAULT_TELEMETRY_PATH = ".opencode/openteam-telemetry.jsonl";
933
+ function parseCostRecordsJsonl(text) {
934
+ const records = [];
935
+ for (const line of text.split(`
936
+ `)) {
937
+ const trimmed = line.trim();
938
+ if (trimmed.length === 0) {
939
+ continue;
940
+ }
941
+ let candidate;
942
+ try {
943
+ candidate = JSON.parse(trimmed);
944
+ } catch {
945
+ continue;
946
+ }
947
+ const parsed = CostRecordSchema.safeParse(candidate);
948
+ if (parsed.success) {
949
+ records.push(parsed.data);
720
950
  }
721
- if(!matched){return false}
722
- try{sessionStorage.setItem('openteam.tab',name)}catch(e){}
723
- return true;
724
951
  }
725
- function connectTerminal(view,id){
726
- if(view.__wired){return}
727
- var root=document.documentElement;
728
- var token=root.getAttribute('data-token');
729
- if(root.getAttribute('data-terminal')!=='1'||!token){return}
730
- var log=view.querySelector('.termlog');
731
- if(!log){return}
732
- view.__wired=true;
733
- function append(cls,text){
734
- var line=document.createElement('div');
735
- line.className='tl '+cls;line.textContent=text;
736
- log.appendChild(line);log.scrollTop=log.scrollHeight;
952
+ return records;
953
+ }
954
+
955
+ // src/telemetry/eventLog.ts
956
+ var DEFAULT_SESSIONS_DIR = ".opencode/openteam/sessions";
957
+ var LEGACY_SESSION_ID = "legacy";
958
+ function parseEventsJsonl(text) {
959
+ const events = [];
960
+ for (const line of text.split(`
961
+ `)) {
962
+ const trimmed = line.trim();
963
+ if (trimmed.length === 0) {
964
+ continue;
737
965
  }
738
- var q='/console/session/'+encodeURIComponent(id);
739
- var tk='token='+encodeURIComponent(token);
740
- if('EventSource' in window){
741
- try{
742
- var es=new EventSource(q+'/stream?'+tk);
743
- es.addEventListener('frame',function(e){try{var f=JSON.parse(e.data);append(f.kind,f.text)}catch(_){}});
744
- es.onerror=function(){/* reconecta solo */};
745
- }catch(_){/* ignore */}
966
+ let candidate;
967
+ try {
968
+ candidate = JSON.parse(trimmed);
969
+ } catch {
970
+ continue;
746
971
  }
747
- var form=view.querySelector('form.terminput');
748
- if(form){
749
- form.addEventListener('submit',function(e){
750
- e.preventDefault();
751
- var inp=form.querySelector('input[name=text]');
752
- var text=inp.value;
753
- if(!text){return}
754
- inp.value='';append('input','› '+text);
755
- fetch(q+'/input?'+tk,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({text:text})})
756
- .then(function(r){if(!r.ok){append('error','✖ envío rechazado ('+r.status+')')}})
757
- .catch(function(){append('error','✖ no se pudo enviar')});
758
- });
972
+ const parsed = OpenTeamEventSchema.safeParse(candidate);
973
+ if (parsed.success) {
974
+ events.push(parsed.data);
759
975
  }
760
- connectPty(view,id,q,tk);
761
976
  }
762
- function connectPty(view,id,q,tk){
763
- var root=document.documentElement;
764
- if(root.getAttribute('data-pty')!=='1'){return}
765
- var pane=view.querySelector('.pty');
766
- if(!pane){return}
767
- var log=pane.querySelector('.ptylog');
768
- var startBtn=pane.querySelector('.pty-start');
769
- var input=pane.querySelector('form.ptyinput');
770
- function plog(text){
771
- var d=document.createElement('span');d.textContent=text;
772
- log.appendChild(d);log.scrollTop=log.scrollHeight;
977
+ return events;
978
+ }
979
+ function costRecordToRouteEvent(record) {
980
+ const event = {
981
+ v: EVENT_SCHEMA_VERSION,
982
+ type: "route",
983
+ ts: record.ts,
984
+ sessionID: record.sessionID ?? LEGACY_SESSION_ID,
985
+ promptHash: record.promptHash,
986
+ promptChars: record.promptChars,
987
+ tier: record.tier,
988
+ routeKind: record.routeKind,
989
+ selected: record.selected,
990
+ rationale: record.rationale,
991
+ estimatedCostUSD: record.estimatedCostUSD,
992
+ baselineCostUSD: record.baselineCostUSD,
993
+ estimatedSavingsUSD: record.estimatedSavingsUSD,
994
+ budgetAction: record.budgetAction
995
+ };
996
+ if (record.tokensIn !== undefined) {
997
+ event.tokensIn = record.tokensIn;
998
+ }
999
+ if (record.tokensOut !== undefined) {
1000
+ event.tokensOut = record.tokensOut;
1001
+ }
1002
+ return event;
1003
+ }
1004
+ function routeEventToCostRecord(event) {
1005
+ const record = {
1006
+ ts: event.ts,
1007
+ sessionID: event.sessionID,
1008
+ promptHash: event.promptHash,
1009
+ promptChars: event.promptChars,
1010
+ tier: event.tier,
1011
+ routeKind: event.routeKind,
1012
+ selected: event.selected,
1013
+ rationale: event.rationale,
1014
+ estimatedCostUSD: event.estimatedCostUSD,
1015
+ baselineCostUSD: event.baselineCostUSD,
1016
+ estimatedSavingsUSD: event.estimatedSavingsUSD,
1017
+ budgetAction: event.budgetAction
1018
+ };
1019
+ if (event.tokensIn !== undefined) {
1020
+ record.tokensIn = event.tokensIn;
1021
+ }
1022
+ if (event.tokensOut !== undefined) {
1023
+ record.tokensOut = event.tokensOut;
1024
+ }
1025
+ return record;
1026
+ }
1027
+ async function readRouteCostRecords(dir, deps) {
1028
+ const events = await readSessionEvents(dir, deps);
1029
+ const records = [];
1030
+ for (const event of events) {
1031
+ if (event.type === "route") {
1032
+ records.push(routeEventToCostRecord(event));
773
1033
  }
774
- if(!startBtn){return}
775
- startBtn.addEventListener('click',function(){
776
- startBtn.disabled=true;plog('Abriendo shell…
777
- ');
778
- fetch(q+'/pty?'+tk,{method:'POST',headers:{'content-type':'application/json'},body:'{}'})
779
- .then(function(r){return r.ok?r.json():Promise.reject(r.status)})
780
- .then(function(info){openPtyWs(info.wsUrl)})
781
- .catch(function(s){plog('✖ no se pudo abrir PTY ('+s+')
782
- ');startBtn.disabled=false});
783
- });
784
- function openPtyWs(url){
785
- try{
786
- var ws=new WebSocket(url);
787
- ws.binaryType='arraybuffer';
788
- ws.onmessage=function(e){
789
- var data=e.data;
790
- if(data instanceof ArrayBuffer){data=new TextDecoder().decode(new Uint8Array(data))}
791
- plog(data);
792
- };
793
- ws.onclose=function(){plog('
794
- [shell cerrada]
795
- ');startBtn.disabled=false};
796
- ws.onerror=function(){plog('
797
- ✖ error de WebSocket
798
- ')};
799
- if(input){
800
- input.hidden=false;
801
- input.addEventListener('submit',function(e){
802
- e.preventDefault();
803
- var c=input.querySelector('input[name=cmd]');
804
- if(!c.value){return}
805
- ws.send(c.value+'\r');c.value='';
806
- });
807
- }
808
- }catch(_){plog('
809
- ✖ WebSocket no disponible
810
- ');startBtn.disabled=false}
1034
+ }
1035
+ return records;
1036
+ }
1037
+ async function readSessionEvents(dir, deps) {
1038
+ const files = (await deps.storage.list(dir)).filter((file) => file.endsWith(".jsonl"));
1039
+ const perFile = await Promise.all(files.map(async (file) => {
1040
+ const text = await deps.storage.read(joinVirtualPath(dir, file));
1041
+ return text === undefined ? [] : parseEventsJsonl(text);
1042
+ }));
1043
+ const events = perFile.flat();
1044
+ if (deps.legacyTelemetryPath !== undefined) {
1045
+ const legacyText = await deps.storage.read(deps.legacyTelemetryPath);
1046
+ if (legacyText !== undefined) {
1047
+ for (const record of parseCostRecordsJsonl(legacyText)) {
1048
+ events.push(costRecordToRouteEvent(record));
1049
+ }
811
1050
  }
812
1051
  }
813
- var bar=document.getElementById('tabbar');
814
- if(bar){
815
- bar.addEventListener('click',function(e){
816
- var t=e.target;
817
- while(t&&t!==bar&&!t.getAttribute('data-tab')){t=t.parentNode}
818
- if(t&&t.getAttribute('data-tab')){selectTab(t.getAttribute('data-tab'))}
819
- });
820
- }
821
- var saved='all';
822
- try{saved=sessionStorage.getItem('openteam.tab')||'all'}catch(e){}
823
- if(!selectTab(saved)){selectTab('all')}
824
- })();
1052
+ return events.sort((a, b) => a.ts - b.ts);
1053
+ }
1054
+
1055
+ // src/storage/index/sqliteIndex.ts
1056
+ var SCHEMA = `
1057
+ CREATE TABLE IF NOT EXISTS events (
1058
+ session_id TEXT NOT NULL,
1059
+ ts INTEGER NOT NULL,
1060
+ type TEXT NOT NULL,
1061
+ payload TEXT NOT NULL
1062
+ );
1063
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, ts);
825
1064
  `;
826
- function renderConsoleHtml(state, options = {}) {
827
- const refreshMs = options.refreshMs ?? 2000;
828
- const terminalEnabled = options.terminalEnabled === true;
829
- const ptyEnabled = options.ptyEnabled === true;
830
- const allBody = [
831
- summaryPanel(state.cost),
832
- totalsPanel(state.totals),
833
- sessionsPanel(state.sessions),
834
- loopPanel(state.loop),
835
- teamPanel(state.team),
836
- tierPanel(state.cost),
837
- routesPanel(state.recentRoutes),
838
- decisionsPanel(state.decisions),
839
- meetingsPanel(state.meetings),
840
- activityPanel(state.activity)
841
- ].join("");
842
- const allView = `<div class="view" data-view="all">${allBody}</div>`;
843
- const sessionViews = state.sessionsDetail.map((tab) => sessionView(tab, terminalEnabled, ptyEnabled)).join("");
844
- const body = allView + sessionViews;
845
- const session = state.session.id === undefined ? "" : ` · sesión ${escapeHtml(state.session.id)}`;
846
- const tokenAttr = terminalEnabled && options.consoleToken !== undefined ? ` data-token="${escapeHtml(options.consoleToken)}" data-terminal="1"${ptyEnabled ? ' data-pty="1"' : ""}` : "";
847
- return [
848
- "<!doctype html>",
849
- `<html lang="es" data-generated="${escapeHtml(state.generatedAt)}" data-refresh="${refreshMs}"${tokenAttr}>`,
850
- "<head>",
851
- '<meta charset="utf-8">',
852
- '<meta name="viewport" content="width=device-width,initial-scale=1">',
853
- "<title>openteam console</title>",
854
- `<style>${STYLE}</style>`,
855
- "</head>",
856
- "<body>",
857
- "<header>",
858
- "<h1>openteam console</h1>",
859
- `<span class="meta">actualizado ${escapeHtml(state.generatedAt)}${session}</span>`,
860
- "</header>",
861
- tabBar(state.sessionsDetail),
862
- `<main>${body}</main>`,
863
- `<script>${CLIENT_JS}</script>`,
864
- "</body>",
865
- "</html>"
866
- ].join("");
1065
+ var INSERT_SQL = "INSERT INTO events (session_id, ts, type, payload) VALUES (?, ?, ?, ?)";
1066
+ var ROLLUP_SQL = `
1067
+ SELECT
1068
+ session_id AS sessionID,
1069
+ COUNT(*) AS eventCount,
1070
+ SUM(CASE WHEN type = 'route' THEN 1 ELSE 0 END) AS routeCount,
1071
+ SUM(CASE WHEN type = 'message' THEN 1 ELSE 0 END) AS messageCount,
1072
+ SUM(CASE WHEN type = 'toolcall' THEN 1 ELSE 0 END) AS toolcallCount,
1073
+ COALESCE(SUM(CASE WHEN type = 'message'
1074
+ THEN json_extract(payload, '$.costUSD') ELSE 0 END), 0) AS realCostUSD,
1075
+ COALESCE(SUM(CASE WHEN type = 'message'
1076
+ THEN json_extract(payload, '$.tokensIn') ELSE 0 END), 0) AS tokensIn,
1077
+ COALESCE(SUM(CASE WHEN type = 'message'
1078
+ THEN json_extract(payload, '$.tokensOut') ELSE 0 END), 0) AS tokensOut,
1079
+ MIN(ts) AS firstTs,
1080
+ MAX(ts) AS lastTs
1081
+ FROM events
1082
+ GROUP BY session_id
1083
+ ORDER BY lastTs DESC, session_id ASC
1084
+ `;
1085
+ function insertEvents(db, events) {
1086
+ const stmt = db.query(INSERT_SQL);
1087
+ db.run("BEGIN");
1088
+ try {
1089
+ for (const event of events) {
1090
+ stmt.run(event.sessionID, event.ts, event.type, JSON.stringify(event));
1091
+ }
1092
+ db.run("COMMIT");
1093
+ } catch (error) {
1094
+ db.run("ROLLBACK");
1095
+ throw error;
1096
+ }
1097
+ }
1098
+ function toNumber(value) {
1099
+ return typeof value === "number" ? value : Number(value ?? 0);
1100
+ }
1101
+ function createSqliteEventIndex(db) {
1102
+ db.exec(SCHEMA);
1103
+ const allEvents = () => {
1104
+ const rows = db.query("SELECT payload FROM events ORDER BY ts ASC").all();
1105
+ const events = [];
1106
+ for (const row of rows) {
1107
+ let candidate;
1108
+ try {
1109
+ candidate = JSON.parse(row.payload);
1110
+ } catch {
1111
+ continue;
1112
+ }
1113
+ const parsed = OpenTeamEventSchema.safeParse(candidate);
1114
+ if (parsed.success) {
1115
+ events.push(parsed.data);
1116
+ }
1117
+ }
1118
+ return events;
1119
+ };
1120
+ return {
1121
+ rebuild(events) {
1122
+ db.run("DELETE FROM events");
1123
+ insertEvents(db, events);
1124
+ },
1125
+ append(events) {
1126
+ insertEvents(db, events);
1127
+ },
1128
+ allEvents,
1129
+ sessionRollups() {
1130
+ const rows = db.query(ROLLUP_SQL).all();
1131
+ return rows.map((row) => ({
1132
+ sessionID: String(row.sessionID),
1133
+ eventCount: toNumber(row.eventCount),
1134
+ routeCount: toNumber(row.routeCount),
1135
+ messageCount: toNumber(row.messageCount),
1136
+ toolcallCount: toNumber(row.toolcallCount),
1137
+ realCostUSD: toNumber(row.realCostUSD),
1138
+ tokensIn: toNumber(row.tokensIn),
1139
+ tokensOut: toNumber(row.tokensOut),
1140
+ firstTs: toNumber(row.firstTs),
1141
+ lastTs: toNumber(row.lastTs)
1142
+ }));
1143
+ },
1144
+ aggregate() {
1145
+ return aggregateEvents(allEvents());
1146
+ },
1147
+ close() {
1148
+ db.close();
1149
+ }
1150
+ };
1151
+ }
1152
+ async function rebuildSqliteIndexFromStorage(index, dir, deps) {
1153
+ const events = await readSessionEvents(dir, deps);
1154
+ index.rebuild(events);
1155
+ return events.length;
867
1156
  }
868
1157
 
869
- // src/commands/report.ts
870
- var TIERS2 = [
1158
+ // src/storage/index/bunSqlite.ts
1159
+ async function createBunSqliteDatabase(path) {
1160
+ const mod = await import("bun:sqlite");
1161
+ return new mod.Database(path);
1162
+ }
1163
+ async function buildSqliteIndexFromConfig(path, sessionsDir, deps) {
1164
+ const db = await createBunSqliteDatabase(path);
1165
+ const index = createSqliteEventIndex(db);
1166
+ const events = await rebuildSqliteIndexFromStorage(index, sessionsDir, deps);
1167
+ return { index, events };
1168
+ }
1169
+
1170
+ // src/web/start.ts
1171
+ import { watch as fsWatch } from "node:fs";
1172
+
1173
+ // src/web/paths.ts
1174
+ var DEFAULT_BACKLOG_PATH = ".opencode/openteam-backlog.md";
1175
+ var DEFAULT_DECISIONS_PATH = ".opencode/openteam-decisions.md";
1176
+
1177
+ // src/web/server.ts
1178
+ import {
1179
+ createServer as createHttpServer
1180
+ } from "node:http";
1181
+
1182
+ // src/console/render.ts
1183
+ var TIERS = [
871
1184
  "trivial",
872
1185
  "simple",
873
1186
  "moderate",
874
1187
  "hard"
875
1188
  ];
876
- function emptyTierSummary() {
877
- return {
878
- trivial: { count: 0, estimatedUSD: 0, savingsUSD: 0 },
879
- simple: { count: 0, estimatedUSD: 0, savingsUSD: 0 },
880
- moderate: { count: 0, estimatedUSD: 0, savingsUSD: 0 },
881
- hard: { count: 0, estimatedUSD: 0, savingsUSD: 0 }
882
- };
883
- }
884
- function summarizeCostRecords(records) {
885
- const report = {
886
- count: 0,
887
- localCount: 0,
888
- frontierCount: 0,
889
- totalEstimatedUSD: 0,
890
- totalBaselineUSD: 0,
891
- totalSavingsUSD: 0,
892
- savingsPct: 0,
893
- tokensIn: 0,
894
- tokensOut: 0,
895
- byTier: emptyTierSummary()
896
- };
897
- for (const record of records) {
898
- report.count += 1;
899
- if (record.routeKind === "local") {
900
- report.localCount += 1;
901
- } else {
902
- report.frontierCount += 1;
903
- }
904
- report.totalEstimatedUSD += record.estimatedCostUSD;
905
- report.totalBaselineUSD += record.baselineCostUSD;
906
- report.totalSavingsUSD += record.estimatedSavingsUSD;
907
- report.tokensIn += record.tokensIn ?? 0;
908
- report.tokensOut += record.tokensOut ?? 0;
909
- const tier = report.byTier[record.tier];
910
- tier.count += 1;
911
- tier.estimatedUSD += record.estimatedCostUSD;
912
- tier.savingsUSD += record.estimatedSavingsUSD;
913
- }
914
- report.savingsPct = report.totalBaselineUSD > 0 ? report.totalSavingsUSD / report.totalBaselineUSD * 100 : 0;
915
- return report;
1189
+ function escapeHtml(value) {
1190
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
916
1191
  }
917
- function usd2(value) {
1192
+ function usd(value) {
918
1193
  return `$${value.toFixed(5)}`;
919
1194
  }
920
- function renderReport(report) {
921
- if (report.count === 0) {
922
- return "openteam report: sin registros de telemetría todavía.";
923
- }
924
- const lines = [
925
- "openteam report:",
926
- ` decisiones: ${report.count} (local ${report.localCount} · frontier ${report.frontierCount})`,
927
- ` coste real: ${usd2(report.totalEstimatedUSD)}`,
928
- ` baseline: ${usd2(report.totalBaselineUSD)}`,
929
- ` ahorro: ${usd2(report.totalSavingsUSD)} (${report.savingsPct.toFixed(2)}%)`,
930
- ` tokens: in ${report.tokensIn} · out ${report.tokensOut}`,
931
- " por tier:"
932
- ];
933
- for (const tier of TIERS2) {
934
- const summary = report.byTier[tier];
935
- lines.push(` ${tier.padEnd(9)} ${summary.count} · coste ${usd2(summary.estimatedUSD)} · ahorro ${usd2(summary.savingsUSD)}`);
936
- }
937
- return lines.join(`
938
- `);
1195
+ function pct(value) {
1196
+ return `${value.toFixed(2)}%`;
939
1197
  }
940
-
941
- // src/telemetry/aggregate.ts
942
- function emptyToolcallStats() {
943
- return { count: 0, ok: 0, failed: 0, totalDurationMs: 0, byTool: {} };
1198
+ function ms(value) {
1199
+ return value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${Math.round(value)}ms`;
944
1200
  }
945
- function emptySession(sessionID, ts) {
946
- return {
947
- sessionID,
948
- firstTs: ts,
949
- lastTs: ts,
950
- routeCount: 0,
951
- localCount: 0,
952
- frontierCount: 0,
953
- estimatedCostUSD: 0,
954
- estimatedSavingsUSD: 0,
955
- realCostUSD: 0,
956
- messageCount: 0,
957
- tokensIn: 0,
958
- tokensOut: 0,
959
- tokensReasoning: 0,
960
- tokensCacheRead: 0,
961
- tokensCacheWrite: 0,
962
- toolcalls: emptyToolcallStats(),
963
- models: [],
964
- agents: []
965
- };
1201
+ function avgMs(stats) {
1202
+ return stats.count === 0 ? "—" : ms(stats.totalDurationMs / stats.count);
966
1203
  }
967
- function pushUnique(list, value) {
968
- if (value !== undefined && !list.includes(value)) {
969
- list.push(value);
1204
+ function agentModel(agent) {
1205
+ return agent.model === undefined ? "hereda default" : `${agent.model.providerID}/${agent.model.modelID}`;
1206
+ }
1207
+ function summaryPanel(cost) {
1208
+ return [
1209
+ '<section class="panel" id="panel-summary">',
1210
+ "<h2>Resumen de routing</h2>",
1211
+ '<div class="grid">',
1212
+ `<div class="stat"><span class="k">Decisiones</span><span class="v">${cost.count}</span></div>`,
1213
+ `<div class="stat"><span class="k">Local</span><span class="v">${cost.localCount}</span></div>`,
1214
+ `<div class="stat"><span class="k">Frontier</span><span class="v">${cost.frontierCount}</span></div>`,
1215
+ `<div class="stat"><span class="k">Coste real</span><span class="v">${usd(cost.totalEstimatedUSD)}</span></div>`,
1216
+ `<div class="stat"><span class="k">Baseline</span><span class="v">${usd(cost.totalBaselineUSD)}</span></div>`,
1217
+ `<div class="stat"><span class="k">Ahorro</span><span class="v good">${usd(cost.totalSavingsUSD)} (${pct(cost.savingsPct)})</span></div>`,
1218
+ `<div class="stat"><span class="k">Tokens in</span><span class="v">${cost.tokensIn}</span></div>`,
1219
+ `<div class="stat"><span class="k">Tokens out</span><span class="v">${cost.tokensOut}</span></div>`,
1220
+ "</div>",
1221
+ "</section>"
1222
+ ].join("");
1223
+ }
1224
+ function tierPanel(cost) {
1225
+ const rows = TIERS.map((tier) => {
1226
+ const summary = cost.byTier[tier];
1227
+ return `<tr><td>${tier}</td><td>${summary.count}</td><td>${usd(summary.estimatedUSD)}</td><td class="good">${usd(summary.savingsUSD)}</td></tr>`;
1228
+ }).join("");
1229
+ return [
1230
+ '<section class="panel" id="panel-tier">',
1231
+ "<h2>Por tier</h2>",
1232
+ "<table><thead><tr><th>Tier</th><th>Nº</th><th>Coste</th><th>Ahorro</th></tr></thead>",
1233
+ `<tbody>${rows}</tbody></table>`,
1234
+ "</section>"
1235
+ ].join("");
1236
+ }
1237
+ function loopItemRow(item) {
1238
+ const box = item.done ? "☑" : "☐";
1239
+ const cls = item.done ? "done" : "open";
1240
+ const who = item.assignee === undefined ? "" : `<span class="who">@${escapeHtml(item.assignee)}</span> `;
1241
+ return `<li class="${cls}"><span class="box">${box}</span> ${who}${escapeHtml(item.text)}</li>`;
1242
+ }
1243
+ function loopPanel(loop) {
1244
+ if (loop === undefined) {
1245
+ return [
1246
+ '<section class="panel" id="panel-loop">',
1247
+ "<h2>Loop</h2>",
1248
+ '<p class="muted">Sin backlog activo (no hay <code>openteam-backlog.md</code>).</p>',
1249
+ "</section>"
1250
+ ].join("");
970
1251
  }
1252
+ const items = loop.items.map(loopItemRow).join("");
1253
+ const commit = loop.lastCommit === undefined ? "" : `<p class="muted">Último commit: <code>${escapeHtml(loop.lastCommit.hash)}</code> ${escapeHtml(loop.lastCommit.subject)}</p>`;
1254
+ return [
1255
+ '<section class="panel" id="panel-loop">',
1256
+ "<h2>Loop</h2>",
1257
+ `<div class="progress" role="progressbar" aria-valuenow="${loop.progressPct}" aria-valuemin="0" aria-valuemax="100"><div class="bar" style="width:${loop.progressPct}%"></div></div>`,
1258
+ `<p class="muted">${loop.done}/${loop.total} completados · ${loop.open} abiertos · ${loop.progressPct}%</p>`,
1259
+ `<ul class="items">${items}</ul>`,
1260
+ commit,
1261
+ "</section>"
1262
+ ].join("");
971
1263
  }
972
- function applyRoute(session, event) {
973
- session.routeCount += 1;
974
- if (event.routeKind === "local") {
975
- session.localCount += 1;
976
- } else {
977
- session.frontierCount += 1;
1264
+ function teamPanel(team) {
1265
+ if (team.length === 0) {
1266
+ return [
1267
+ '<section class="panel" id="panel-team">',
1268
+ "<h2>Equipo</h2>",
1269
+ '<p class="muted">Sin agentes en <code>.opencode/agent/</code>.</p>',
1270
+ "</section>"
1271
+ ].join("");
978
1272
  }
979
- session.estimatedCostUSD += event.estimatedCostUSD;
980
- session.estimatedSavingsUSD += event.estimatedSavingsUSD;
981
- pushUnique(session.models, `${event.selected.providerID}/${event.selected.modelID}`);
982
- pushUnique(session.agents, event.agent);
1273
+ const rows = team.map((agent) => `<tr><td>${escapeHtml(agent.name)}</td><td>${escapeHtml(agent.mode)}</td><td>${escapeHtml(agentModel(agent))}</td></tr>`).join("");
1274
+ return [
1275
+ '<section class="panel" id="panel-team">',
1276
+ "<h2>Equipo</h2>",
1277
+ "<table><thead><tr><th>Agente</th><th>Modo</th><th>LLM</th></tr></thead>",
1278
+ `<tbody>${rows}</tbody></table>`,
1279
+ "</section>"
1280
+ ].join("");
983
1281
  }
984
- function applyMessage(session, event) {
985
- session.messageCount += 1;
986
- session.realCostUSD += event.costUSD;
987
- session.tokensIn += event.tokensIn;
988
- session.tokensOut += event.tokensOut;
989
- session.tokensReasoning += event.tokensReasoning;
990
- session.tokensCacheRead += event.tokensCacheRead;
991
- session.tokensCacheWrite += event.tokensCacheWrite;
992
- pushUnique(session.models, `${event.providerID}/${event.modelID}`);
993
- pushUnique(session.agents, event.agent);
1282
+ function routeRow(route) {
1283
+ return `<tr><td>${route.tier}</td><td class="${route.routeKind}">${route.routeKind}</td><td>${escapeHtml(route.model)}</td><td class="hash">${escapeHtml(route.promptHash)}</td><td>${usd(route.estimatedCostUSD)}</td><td class="good">${usd(route.estimatedSavingsUSD)}</td></tr>`;
994
1284
  }
995
- function applyToolUsage(stats, event) {
996
- stats.count += 1;
997
- stats.totalDurationMs += event.durationMs;
998
- if (event.ok) {
999
- stats.ok += 1;
1000
- } else {
1001
- stats.failed += 1;
1285
+ function routesPanel(routes) {
1286
+ if (routes.length === 0) {
1287
+ return [
1288
+ '<section class="panel" id="panel-routes">',
1289
+ "<h2>Decisiones recientes</h2>",
1290
+ '<p class="muted">Sin decisiones de routing todavía.</p>',
1291
+ "</section>"
1292
+ ].join("");
1002
1293
  }
1294
+ const rows = routes.map(routeRow).join("");
1295
+ return [
1296
+ '<section class="panel" id="panel-routes">',
1297
+ "<h2>Decisiones recientes</h2>",
1298
+ "<table><thead><tr><th>Tier</th><th>Ruta</th><th>Modelo</th><th>Prompt (hash)</th><th>Coste</th><th>Ahorro</th></tr></thead>",
1299
+ `<tbody>${rows}</tbody></table>`,
1300
+ "</section>"
1301
+ ].join("");
1003
1302
  }
1004
- function applyToolcall(session, event) {
1005
- applyToolUsage(session.toolcalls, event);
1006
- const byTool = session.toolcalls.byTool[event.tool] ?? {
1007
- count: 0,
1008
- ok: 0,
1009
- failed: 0,
1010
- totalDurationMs: 0
1011
- };
1012
- applyToolUsage(byTool, event);
1013
- session.toolcalls.byTool[event.tool] = byTool;
1014
- pushUnique(session.agents, event.agent);
1303
+ function activityRow(entry) {
1304
+ const who = entry.agent === undefined ? "" : `<span class="who">${escapeHtml(entry.agent)}</span> `;
1305
+ return `<li class="${entry.kind}"><span class="kind">${entry.kind}</span> ${who}${escapeHtml(entry.summary)}</li>`;
1015
1306
  }
1016
- function applyEndpoint(session, event) {
1017
- if (session.endpoint !== undefined && session.endpoint.lastSeenTs >= event.ts) {
1018
- return;
1019
- }
1020
- const endpoint = {
1021
- url: event.url,
1022
- lastSeenTs: event.ts
1023
- };
1024
- if (event.directory !== undefined) {
1025
- endpoint.directory = event.directory;
1026
- }
1027
- if (event.worktree !== undefined) {
1028
- endpoint.worktree = event.worktree;
1029
- }
1030
- if (event.title !== undefined) {
1031
- endpoint.title = event.title;
1307
+ function activityPanel(activity) {
1308
+ if (activity.length === 0) {
1309
+ return [
1310
+ '<section class="panel" id="panel-activity">',
1311
+ "<h2>Actividad</h2>",
1312
+ '<p class="muted">Sin actividad registrada.</p>',
1313
+ "</section>"
1314
+ ].join("");
1032
1315
  }
1033
- session.endpoint = endpoint;
1316
+ const rows = activity.map(activityRow).join("");
1317
+ return [
1318
+ '<section class="panel" id="panel-activity">',
1319
+ "<h2>Actividad</h2>",
1320
+ `<ul class="timeline">${rows}</ul>`,
1321
+ "</section>"
1322
+ ].join("");
1034
1323
  }
1035
- function mergeTool(target, source) {
1036
- target.count += source.count;
1037
- target.ok += source.ok;
1038
- target.failed += source.failed;
1039
- target.totalDurationMs += source.totalDurationMs;
1324
+ function totalsPanel(totals) {
1325
+ const t = totals.toolcalls;
1326
+ return [
1327
+ '<section class="panel" id="panel-totals">',
1328
+ "<h2>Telemetría real (todas las sesiones)</h2>",
1329
+ '<div class="grid">',
1330
+ `<div class="stat"><span class="k">Sesiones</span><span class="v">${totals.sessions}</span></div>`,
1331
+ `<div class="stat"><span class="k">Mensajes</span><span class="v">${totals.messageCount}</span></div>`,
1332
+ `<div class="stat"><span class="k">Coste real</span><span class="v">${usd(totals.realCostUSD)}</span></div>`,
1333
+ `<div class="stat"><span class="k">Ahorro est.</span><span class="v good">${usd(totals.estimatedSavingsUSD)}</span></div>`,
1334
+ `<div class="stat"><span class="k">Tokens in</span><span class="v">${totals.tokensIn}</span></div>`,
1335
+ `<div class="stat"><span class="k">Tokens out</span><span class="v">${totals.tokensOut}</span></div>`,
1336
+ `<div class="stat"><span class="k">Reasoning</span><span class="v">${totals.tokensReasoning}</span></div>`,
1337
+ `<div class="stat"><span class="k">Cache r/w</span><span class="v">${totals.tokensCacheRead}/${totals.tokensCacheWrite}</span></div>`,
1338
+ `<div class="stat"><span class="k">Toolcalls</span><span class="v">${t.count} <span class="good">${t.ok}✓</span> <span class="frontier">${t.failed}✗</span></span></div>`,
1339
+ `<div class="stat"><span class="k">Dur. media tool</span><span class="v">${avgMs(t)}</span></div>`,
1340
+ "</div>",
1341
+ "</section>"
1342
+ ].join("");
1040
1343
  }
1041
- function accumulateTotals(totals, session) {
1042
- totals.sessions += 1;
1043
- totals.routeCount += session.routeCount;
1044
- totals.localCount += session.localCount;
1045
- totals.frontierCount += session.frontierCount;
1046
- totals.estimatedCostUSD += session.estimatedCostUSD;
1047
- totals.estimatedSavingsUSD += session.estimatedSavingsUSD;
1048
- totals.realCostUSD += session.realCostUSD;
1049
- totals.messageCount += session.messageCount;
1050
- totals.tokensIn += session.tokensIn;
1051
- totals.tokensOut += session.tokensOut;
1052
- totals.tokensReasoning += session.tokensReasoning;
1053
- totals.tokensCacheRead += session.tokensCacheRead;
1054
- totals.tokensCacheWrite += session.tokensCacheWrite;
1055
- mergeTool(totals.toolcalls, session.toolcalls);
1056
- for (const [tool, usage] of Object.entries(session.toolcalls.byTool)) {
1057
- const target = totals.toolcalls.byTool[tool] ?? {
1058
- count: 0,
1059
- ok: 0,
1060
- failed: 0,
1061
- totalDurationMs: 0
1062
- };
1063
- mergeTool(target, usage);
1064
- totals.toolcalls.byTool[tool] = target;
1344
+ function sessionRow(session) {
1345
+ const models = session.models.map(escapeHtml).join("<br>") || "—";
1346
+ return [
1347
+ "<tr>",
1348
+ `<td class="hash">${escapeHtml(session.sessionID)}</td>`,
1349
+ `<td>${models}</td>`,
1350
+ `<td><span class="local">${session.localCount}</span>/<span class="frontier">${session.frontierCount}</span></td>`,
1351
+ `<td>${usd(session.realCostUSD)}</td>`,
1352
+ `<td>${session.tokensIn}/${session.tokensOut}</td>`,
1353
+ `<td>${session.toolcalls.count} (${avgMs(session.toolcalls)})</td>`,
1354
+ "</tr>"
1355
+ ].join("");
1356
+ }
1357
+ function sessionsPanel(sessions) {
1358
+ if (sessions.length === 0) {
1359
+ return [
1360
+ '<section class="panel" id="panel-sessions">',
1361
+ "<h2>Sesiones</h2>",
1362
+ '<p class="muted">Sin sesiones registradas todavía.</p>',
1363
+ "</section>"
1364
+ ].join("");
1065
1365
  }
1366
+ const rows = sessions.map(sessionRow).join("");
1367
+ return [
1368
+ '<section class="panel" id="panel-sessions">',
1369
+ "<h2>Sesiones</h2>",
1370
+ "<table><thead><tr><th>Sesión</th><th>Modelos</th><th>L/F</th><th>Coste</th><th>Tok in/out</th><th>Tools</th></tr></thead>",
1371
+ `<tbody>${rows}</tbody></table>`,
1372
+ "</section>"
1373
+ ].join("");
1066
1374
  }
1067
- function toDecision(event) {
1068
- const view = {
1069
- ts: event.ts,
1070
- sessionID: event.sessionID,
1071
- summary: event.summary
1072
- };
1073
- if (event.agent !== undefined) {
1074
- view.agent = event.agent;
1375
+ function decisionRow(decision) {
1376
+ const who = decision.agent === undefined ? "" : `<span class="who">${escapeHtml(decision.agent)}</span> `;
1377
+ const tags = decision.tags === undefined || decision.tags.length === 0 ? "" : ` ${decision.tags.map((tag) => `<span class="tag">#${escapeHtml(tag)}</span>`).join(" ")}`;
1378
+ return `<li class="decision"><span class="kind">decision</span> ${who}${escapeHtml(decision.summary)}${tags}</li>`;
1379
+ }
1380
+ function decisionsPanel(decisions) {
1381
+ if (decisions.length === 0) {
1382
+ return [
1383
+ '<section class="panel" id="panel-decisions">',
1384
+ "<h2>Decisiones</h2>",
1385
+ '<p class="muted">Sin decisiones registradas (el scribe escribe en <code>openteam-decisions.md</code>).</p>',
1386
+ "</section>"
1387
+ ].join("");
1075
1388
  }
1076
- if (event.tags !== undefined) {
1077
- view.tags = [...event.tags];
1389
+ const rows = decisions.map(decisionRow).join("");
1390
+ return [
1391
+ '<section class="panel" id="panel-decisions">',
1392
+ "<h2>Decisiones</h2>",
1393
+ `<ul class="timeline">${rows}</ul>`,
1394
+ "</section>"
1395
+ ].join("");
1396
+ }
1397
+ function meetingRow(meeting) {
1398
+ const roles = meeting.roles.map(escapeHtml).join(", ");
1399
+ const purpose = meeting.purpose === undefined ? "" : `: ${escapeHtml(meeting.purpose)}`;
1400
+ return `<li class="agent"><span class="kind">reunión</span> <span class="who">${roles}</span>${purpose}</li>`;
1401
+ }
1402
+ function meetingsPanel(meetings) {
1403
+ if (meetings.length === 0) {
1404
+ return [
1405
+ '<section class="panel" id="panel-meetings">',
1406
+ "<h2>Reuniones</h2>",
1407
+ '<p class="muted">Sin reuniones multi-agente todavía.</p>',
1408
+ "</section>"
1409
+ ].join("");
1078
1410
  }
1079
- return view;
1411
+ const rows = meetings.map(meetingRow).join("");
1412
+ return [
1413
+ '<section class="panel" id="panel-meetings">',
1414
+ "<h2>Reuniones</h2>",
1415
+ `<ul class="timeline">${rows}</ul>`,
1416
+ "</section>"
1417
+ ].join("");
1418
+ }
1419
+ var STATUS_LABELS = {
1420
+ running: "activa",
1421
+ idle: "en reposo",
1422
+ "waiting-input": "esperando input",
1423
+ ended: "finalizada"
1424
+ };
1425
+ function statusBadge(status) {
1426
+ return `<span class="badge status-${status}">${STATUS_LABELS[status]}</span>`;
1080
1427
  }
1081
- function toMeeting(event) {
1082
- const view = {
1083
- ts: event.ts,
1084
- sessionID: event.sessionID,
1085
- batchID: event.batchID,
1086
- roles: [...event.roles]
1087
- };
1088
- if (event.purpose !== undefined) {
1089
- view.purpose = event.purpose;
1090
- }
1091
- if (event.decisionIDs !== undefined) {
1092
- view.decisionIDs = [...event.decisionIDs];
1093
- }
1094
- return view;
1428
+ function tabBar(sessions) {
1429
+ const allTab = '<button type="button" class="tab" data-tab="all">Todas</button>';
1430
+ const tabs = sessions.map((tab) => {
1431
+ const id = escapeHtml(tab.sessionID);
1432
+ const short = escapeHtml(shortSessionID(tab.sessionID));
1433
+ return `<button type="button" class="tab" data-tab="${id}"><span class="dot status-${tab.status}"></span>${short}</button>`;
1434
+ }).join("");
1435
+ return `<nav class="tabs" id="tabbar">${allTab}${tabs}</nav>`;
1095
1436
  }
1096
- function toActivity(event) {
1097
- const view = {
1098
- ts: event.ts,
1099
- sessionID: event.sessionID,
1100
- kind: event.kind,
1101
- summary: event.summary
1102
- };
1103
- if (event.agent !== undefined) {
1104
- view.agent = event.agent;
1105
- }
1106
- return view;
1437
+ function shortSessionID(sessionID) {
1438
+ return sessionID.length <= 12 ? sessionID : `${sessionID.slice(0, 12)}…`;
1107
1439
  }
1108
- function byTsDesc(list) {
1109
- return [...list].sort((a, b) => b.ts - a.ts);
1440
+ function sessionHeaderPanel(tab) {
1441
+ const endpoint = tab.endpoint === undefined ? '<span class="muted">endpoint no anunciado</span>' : `<code>${escapeHtml(tab.endpoint.url)}</code>`;
1442
+ const dir = tab.endpoint?.directory === undefined ? "" : `<div class="stat"><span class="k">Directorio</span><span class="v mono">${escapeHtml(tab.endpoint.directory)}</span></div>`;
1443
+ const title = tab.endpoint?.title === undefined ? "" : `<div class="stat"><span class="k">Título</span><span class="v">${escapeHtml(tab.endpoint.title)}</span></div>`;
1444
+ const s = tab.summary;
1445
+ return [
1446
+ '<section class="panel">',
1447
+ `<h2>Sesión ${escapeHtml(shortSessionID(tab.sessionID))} ${statusBadge(tab.status)}</h2>`,
1448
+ '<div class="grid">',
1449
+ `<div class="stat"><span class="k">Endpoint</span><span class="v">${endpoint}</span></div>`,
1450
+ dir,
1451
+ title,
1452
+ `<div class="stat"><span class="k">Coste real</span><span class="v">${usd(s.realCostUSD)}</span></div>`,
1453
+ `<div class="stat"><span class="k">Tokens in/out</span><span class="v">${s.tokensIn}/${s.tokensOut}</span></div>`,
1454
+ `<div class="stat"><span class="k">Local/Frontier</span><span class="v"><span class="local">${s.localCount}</span>/<span class="frontier">${s.frontierCount}</span></span></div>`,
1455
+ `<div class="stat"><span class="k">Toolcalls</span><span class="v">${s.toolcalls.count} (${avgMs(s.toolcalls)})</span></div>`,
1456
+ "</div>",
1457
+ "</section>"
1458
+ ].join("");
1110
1459
  }
1111
- function aggregateEvents(events) {
1112
- const sessions = new Map;
1113
- const decisions = [];
1114
- const meetings = [];
1115
- const activity = [];
1116
- const sessionFor = (sessionID, ts) => {
1117
- const existing = sessions.get(sessionID);
1118
- if (existing === undefined) {
1119
- const created = emptySession(sessionID, ts);
1120
- sessions.set(sessionID, created);
1121
- return created;
1122
- }
1123
- existing.firstTs = Math.min(existing.firstTs, ts);
1124
- existing.lastTs = Math.max(existing.lastTs, ts);
1125
- return existing;
1126
- };
1127
- for (const event of events) {
1128
- switch (event.type) {
1129
- case "route":
1130
- applyRoute(sessionFor(event.sessionID, event.ts), event);
1131
- break;
1132
- case "message":
1133
- applyMessage(sessionFor(event.sessionID, event.ts), event);
1134
- break;
1135
- case "toolcall":
1136
- applyToolcall(sessionFor(event.sessionID, event.ts), event);
1137
- break;
1138
- case "session-endpoint":
1139
- applyEndpoint(sessionFor(event.sessionID, event.ts), event);
1140
- break;
1141
- case "decision":
1142
- decisions.push(toDecision(event));
1143
- break;
1144
- case "meeting":
1145
- meetings.push(toMeeting(event));
1146
- break;
1147
- case "activity":
1148
- activity.push(toActivity(event));
1149
- break;
1150
- }
1151
- }
1152
- const totals = {
1153
- sessions: 0,
1154
- routeCount: 0,
1155
- localCount: 0,
1156
- frontierCount: 0,
1157
- estimatedCostUSD: 0,
1158
- estimatedSavingsUSD: 0,
1159
- realCostUSD: 0,
1160
- messageCount: 0,
1161
- tokensIn: 0,
1162
- tokensOut: 0,
1163
- tokensReasoning: 0,
1164
- tokensCacheRead: 0,
1165
- tokensCacheWrite: 0,
1166
- toolcalls: emptyToolcallStats()
1167
- };
1168
- const summaries = [...sessions.values()].sort((a, b) => b.lastTs - a.lastTs);
1169
- for (const summary of summaries) {
1170
- summary.models.sort();
1171
- summary.agents.sort();
1172
- accumulateTotals(totals, summary);
1173
- }
1174
- return {
1175
- sessions: summaries,
1176
- totals,
1177
- decisions: byTsDesc(decisions),
1178
- meetings: byTsDesc(meetings),
1179
- activity: byTsDesc(activity)
1180
- };
1460
+ function terminalPanel(tab) {
1461
+ return [
1462
+ '<section class="panel term">',
1463
+ "<h2>Terminal (sesión)</h2>",
1464
+ '<pre class="termlog" aria-live="polite"></pre>',
1465
+ '<form class="terminput" autocomplete="off">',
1466
+ `<input type="text" name="text" placeholder="Enviar prompt a ${escapeHtml(shortSessionID(tab.sessionID))}…" aria-label="Prompt a la sesión">`,
1467
+ '<button type="submit">Enviar</button>',
1468
+ "</form>",
1469
+ "</section>"
1470
+ ].join("");
1181
1471
  }
1182
-
1183
- // src/storage/path.ts
1184
- class StoragePathError extends Error {
1185
- constructor(message) {
1186
- super(message);
1187
- this.name = "StoragePathError";
1188
- }
1472
+ function ptyPanel(tab) {
1473
+ return [
1474
+ `<section class="panel pty" data-pty-session="${escapeHtml(tab.sessionID)}">`,
1475
+ "<h2>Terminal (PTY)</h2>",
1476
+ '<pre class="ptylog" aria-live="polite"></pre>',
1477
+ '<form class="ptyinput" autocomplete="off" hidden>',
1478
+ '<input type="text" name="cmd" placeholder="Comando…" aria-label="Comando de la terminal">',
1479
+ '<button type="submit">Ejecutar</button>',
1480
+ "</form>",
1481
+ '<button type="button" class="pty-start">Abrir shell</button>',
1482
+ "</section>"
1483
+ ].join("");
1189
1484
  }
1190
- function normalizeVirtualPath(input) {
1191
- const segments = [];
1192
- for (const rawSegment of input.split(/[\\/]+/)) {
1193
- if (rawSegment === "" || rawSegment === ".") {
1194
- continue;
1195
- }
1196
- if (rawSegment === "..") {
1197
- if (segments.length === 0) {
1198
- throw new StoragePathError(`la ruta "${input}" escapa por encima de la raíz`);
1199
- }
1200
- segments.pop();
1201
- continue;
1202
- }
1203
- segments.push(rawSegment);
1485
+ function sessionView(tab, terminalEnabled, ptyEnabled) {
1486
+ const body = [
1487
+ sessionHeaderPanel(tab),
1488
+ terminalEnabled ? terminalPanel(tab) : "",
1489
+ terminalEnabled && ptyEnabled ? ptyPanel(tab) : "",
1490
+ routesPanel(tab.recentRoutes),
1491
+ decisionsPanel(tab.decisions),
1492
+ meetingsPanel(tab.meetings),
1493
+ activityPanel(tab.activity)
1494
+ ].join("");
1495
+ return `<div class="view" data-view="${escapeHtml(tab.sessionID)}" hidden>${body}</div>`;
1496
+ }
1497
+ var STYLE = `
1498
+ :root{color-scheme:dark;--bg:#0f1115;--panel:#171a21;--fg:#e6e8eb;--muted:#8b929c;--good:#3fb950;--local:#58a6ff;--frontier:#d29922;--line:#262b34}
1499
+ *{box-sizing:border-box}
1500
+ body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 ui-sans-serif,system-ui,Segoe UI,Roboto,sans-serif}
1501
+ header{padding:16px 24px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}
1502
+ header h1{font-size:18px;margin:0}
1503
+ header .meta{color:var(--muted);font-size:12px}
1504
+ main{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;padding:24px}
1505
+ .panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px}
1506
+ .panel h2{font-size:14px;margin:0 0 12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
1507
+ .grid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
1508
+ .stat{display:flex;flex-direction:column;gap:2px}
1509
+ .stat .k{color:var(--muted);font-size:12px}
1510
+ .stat .v{font-size:18px;font-weight:600}
1511
+ .good{color:var(--good)}
1512
+ .local{color:var(--local)}
1513
+ .frontier{color:var(--frontier)}
1514
+ .muted{color:var(--muted)}
1515
+ table{width:100%;border-collapse:collapse;font-size:13px}
1516
+ th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line)}
1517
+ th{color:var(--muted);font-weight:600}
1518
+ .hash{font-family:ui-monospace,Consolas,monospace;color:var(--muted)}
1519
+ .progress{height:10px;background:#0b0d11;border-radius:6px;overflow:hidden;border:1px solid var(--line)}
1520
+ .progress .bar{height:100%;background:var(--good)}
1521
+ ul.items,ul.timeline{list-style:none;margin:8px 0 0;padding:0;max-height:320px;overflow:auto}
1522
+ ul.items li,ul.timeline li{padding:4px 0;border-bottom:1px solid var(--line)}
1523
+ ul.items li.done{color:var(--muted)}
1524
+ ul.items .box{font-family:monospace}
1525
+ .who{color:var(--local)}
1526
+ .tag{color:var(--frontier);font-size:12px}
1527
+ .kind{display:inline-block;min-width:66px;color:var(--muted);font-size:12px}
1528
+ code{font-family:ui-monospace,Consolas,monospace;background:#0b0d11;padding:1px 5px;border-radius:4px}
1529
+ .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px}
1530
+ .tabs{display:flex;gap:6px;padding:12px 24px 0;flex-wrap:wrap;border-bottom:1px solid var(--line)}
1531
+ .tabs .tab{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-bottom:none;border-radius:8px 8px 0 0;padding:8px 14px;cursor:pointer;font:inherit;display:flex;align-items:center;gap:6px}
1532
+ .tabs .tab.active{background:var(--bg);border-color:var(--local);color:var(--fg)}
1533
+ .dot{width:8px;height:8px;border-radius:50%;display:inline-block;background:var(--muted)}
1534
+ .badge{font-size:11px;padding:1px 8px;border-radius:10px;border:1px solid var(--line);color:var(--muted);vertical-align:middle}
1535
+ .status-running{background:var(--good)}
1536
+ .badge.status-running{color:var(--good);border-color:var(--good)}
1537
+ .status-idle{background:var(--muted)}
1538
+ .badge.status-idle{color:var(--muted)}
1539
+ .status-waiting-input{background:var(--frontier)}
1540
+ .badge.status-waiting-input{color:var(--frontier);border-color:var(--frontier)}
1541
+ .status-ended{background:#6e7681}
1542
+ .badge.status-ended{color:#6e7681}
1543
+ .term .termlog{background:#0b0d11;border:1px solid var(--line);border-radius:8px;padding:10px;max-height:340px;overflow:auto;font-family:ui-monospace,Consolas,monospace;font-size:12px;white-space:pre-wrap}
1544
+ .term .tl{padding:1px 0}
1545
+ .term .tl.input{color:var(--local)}
1546
+ .term .tl.tool{color:var(--frontier)}
1547
+ .term .tl.status{color:var(--muted)}
1548
+ .term .tl.error{color:#f85149}
1549
+ .term .tl.permission{color:var(--frontier);font-weight:600}
1550
+ .term form.terminput{display:flex;gap:8px;margin-top:10px}
1551
+ .term form.terminput input{flex:1;background:#0b0d11;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:inherit}
1552
+ .term form.terminput button{background:var(--local);color:#04122b;border:none;border-radius:8px;padding:8px 16px;cursor:pointer;font:inherit;font-weight:600}
1553
+ .pty .ptylog{background:#05070a;border:1px solid var(--line);border-radius:8px;padding:10px;max-height:340px;overflow:auto;font-family:ui-monospace,Consolas,monospace;font-size:12px;white-space:pre-wrap;color:#c8d1dc}
1554
+ .pty form.ptyinput{display:flex;gap:8px;margin-top:10px}
1555
+ .pty form.ptyinput input{flex:1;background:#05070a;color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:inherit}
1556
+ .pty form.ptyinput button,.pty .pty-start{background:var(--frontier);color:#1a1200;border:none;border-radius:8px;padding:8px 16px;cursor:pointer;font:inherit;font-weight:600;margin-top:8px}
1557
+ .pty .pty-start:disabled{opacity:.5;cursor:default}
1558
+ `;
1559
+ var CLIENT_JS = `
1560
+ (function(){
1561
+ function reloadIfChanged(prev){
1562
+ fetch('/api/state').then(function(r){return r.json()}).then(function(s){
1563
+ if(s.generatedAt!==prev){location.reload()}
1564
+ }).catch(function(){});
1565
+ }
1566
+ var current=document.documentElement.getAttribute('data-generated')||'';
1567
+ if('EventSource' in window){
1568
+ try{
1569
+ var es=new EventSource('/events');
1570
+ es.addEventListener('state',function(){location.reload()});
1571
+ es.onerror=function(){/* fallback below */};
1572
+ }catch(e){/* ignore */}
1204
1573
  }
1205
- return segments.join("/");
1206
- }
1207
- function joinVirtualPath(...segments) {
1208
- return normalizeVirtualPath(segments.join("/"));
1209
- }
1210
- function splitVirtualPath(path) {
1211
- const normalized = normalizeVirtualPath(path);
1212
- return normalized === "" ? [] : normalized.split("/");
1213
- }
1214
-
1215
- // src/telemetry/events.ts
1216
- import { z as z4 } from "zod";
1217
-
1218
- // src/capabilities/types.ts
1219
- import { z as z2 } from "zod";
1220
- var CapabilityTierSchema = z2.union([
1221
- z2.literal(0),
1222
- z2.literal(1),
1223
- z2.literal(2),
1224
- z2.literal(3),
1225
- z2.literal(4),
1226
- z2.literal(5)
1227
- ]);
1228
- var ComplexityTierSchema = z2.enum([
1229
- "trivial",
1230
- "simple",
1231
- "moderate",
1232
- "hard"
1233
- ]);
1234
- var ModelCapabilityProfileSchema = z2.object({
1235
- ref: z2.object({
1236
- providerID: z2.string().min(1),
1237
- modelID: z2.string().min(1)
1238
- }),
1239
- kind: z2.enum(["local", "frontier", "router"]),
1240
- contextWindow: z2.number().int().positive(),
1241
- maxOutputTokens: z2.number().int().positive(),
1242
- supportsToolCalling: z2.boolean(),
1243
- supportsVision: z2.boolean(),
1244
- reasoningTier: CapabilityTierSchema,
1245
- codeQualityTier: CapabilityTierSchema,
1246
- costPer1M: z2.object({
1247
- inputUSD: z2.number().min(0),
1248
- outputUSD: z2.number().min(0)
1249
- }),
1250
- availability: z2.enum(["available", "degraded", "unavailable"])
1251
- });
1252
-
1253
- // src/config/schema.ts
1254
- import { z as z3 } from "zod";
1255
- var ModelRefSchema = z3.object({
1256
- providerID: z3.string().min(1),
1257
- modelID: z3.string().min(1)
1258
- });
1259
- var RouterModeSchema = z3.enum(["economy", "balanced", "quality"]);
1260
- var PrivacyModeSchema = z3.enum([
1261
- "forceLocalOnSensitive",
1262
- "consentBeforeFrontier",
1263
- "off"
1264
- ]);
1265
- var BaselineModeSchema = z3.enum(["auto", "pinned"]);
1266
- var ConsoleHostSchema = z3.enum(["127.0.0.1", "localhost"]);
1267
- var ConsoleConfigSchema = z3.object({
1268
- host: ConsoleHostSchema.default("127.0.0.1"),
1269
- port: z3.number().int().min(1024).max(65535).default(4599),
1270
- autoPortFallback: z3.boolean().default(true),
1271
- refreshMs: z3.number().int().min(250).default(2000),
1272
- recentRoutes: z3.number().int().positive().default(50),
1273
- openBrowser: z3.boolean().default(false),
1274
- terminal: z3.object({
1275
- enabled: z3.boolean().default(true),
1276
- pty: z3.boolean().optional()
1277
- }).default({ enabled: true })
1278
- }).default({
1279
- host: "127.0.0.1",
1280
- port: 4599,
1281
- autoPortFallback: true,
1282
- refreshMs: 2000,
1283
- recentRoutes: 50,
1284
- openBrowser: false,
1285
- terminal: { enabled: true }
1286
- });
1287
- var defaultLocalModel = {
1288
- providerID: "ollama",
1289
- modelID: "qwen3:8b"
1290
- };
1291
- var defaultFrontierModel = {
1292
- providerID: "anthropic",
1293
- modelID: "claude-sonnet-4-5"
1294
- };
1295
- var LocalRuntimeSchema = z3.object({
1296
- id: z3.enum(["ollama", "lmstudio", "foundry-local"]),
1297
- enabled: z3.boolean().default(true),
1298
- baseURL: z3.string().url().optional(),
1299
- discovery: z3.enum(["cli", "sdk", "manual"]).optional(),
1300
- defaultModel: ModelRefSchema
1301
- });
1302
- var OpenTeamConfigObjectSchema = z3.object({
1303
- baseline: z3.object({
1304
- mode: BaselineModeSchema.default("auto"),
1305
- pinnedModel: ModelRefSchema.nullable().default(null),
1306
- hardDefault: ModelRefSchema.default(defaultFrontierModel)
1307
- }).default({
1308
- mode: "auto",
1309
- pinnedModel: null,
1310
- hardDefault: defaultFrontierModel
1311
- }),
1312
- router: z3.object({
1313
- mode: RouterModeSchema.default("balanced"),
1314
- localDefault: ModelRefSchema.default(defaultLocalModel),
1315
- trivialPromptMaxChars: z3.number().int().positive().default(280),
1316
- frontierPromptMinChars: z3.number().int().positive().default(2000)
1317
- }).default({
1318
- mode: "balanced",
1319
- localDefault: defaultLocalModel,
1320
- trivialPromptMaxChars: 280,
1321
- frontierPromptMinChars: 2000
1322
- }),
1323
- local: z3.object({
1324
- runtimes: z3.array(LocalRuntimeSchema).min(1).default([
1325
- {
1326
- id: "ollama",
1327
- enabled: true,
1328
- baseURL: "http://localhost:11434/v1",
1329
- defaultModel: { providerID: "ollama", modelID: "qwen3:8b" }
1330
- }
1331
- ])
1332
- }).default({
1333
- runtimes: [
1334
- {
1335
- id: "ollama",
1336
- enabled: true,
1337
- baseURL: "http://localhost:11434/v1",
1338
- defaultModel: defaultLocalModel
1339
- }
1340
- ]
1341
- }),
1342
- budgets: z3.object({
1343
- sessionUSD: z3.number().positive().optional(),
1344
- monthlyUSD: z3.number().positive().optional(),
1345
- frontierTokensPerSession: z3.number().int().positive().optional(),
1346
- hardStopOnBudgetExhaustion: z3.boolean().default(false)
1347
- }).default({ hardStopOnBudgetExhaustion: false }),
1348
- privacyMode: PrivacyModeSchema.default("forceLocalOnSensitive"),
1349
- console: ConsoleConfigSchema
1350
- });
1351
- var OpenTeamConfigSchema = OpenTeamConfigObjectSchema;
1352
-
1353
- // src/telemetry/events.ts
1354
- var EVENT_SCHEMA_VERSION = 1;
1355
- var EventBaseSchema = z4.object({
1356
- v: z4.literal(EVENT_SCHEMA_VERSION),
1357
- ts: z4.number().finite(),
1358
- sessionID: z4.string().min(1)
1359
- });
1360
- var RouteEventSchema = EventBaseSchema.extend({
1361
- type: z4.literal("route"),
1362
- promptHash: z4.string().min(1),
1363
- promptChars: z4.number().int().min(0),
1364
- tier: ComplexityTierSchema,
1365
- routeKind: z4.enum(["local", "frontier"]),
1366
- selected: ModelRefSchema,
1367
- rationale: z4.string(),
1368
- estimatedCostUSD: z4.number().finite().min(0),
1369
- baselineCostUSD: z4.number().finite().min(0),
1370
- estimatedSavingsUSD: z4.number().finite(),
1371
- budgetAction: z4.string().min(1),
1372
- tokensIn: z4.number().int().min(0).optional(),
1373
- tokensOut: z4.number().int().min(0).optional(),
1374
- decisionID: z4.string().min(1).optional(),
1375
- agent: z4.string().min(1).optional(),
1376
- success: z4.boolean().optional(),
1377
- batchID: z4.string().min(1).optional(),
1378
- failureReason: z4.string().min(1).optional(),
1379
- failureStage: z4.string().min(1).optional()
1380
- });
1381
- var MessageEventSchema = EventBaseSchema.extend({
1382
- type: z4.literal("message"),
1383
- providerID: z4.string().min(1),
1384
- modelID: z4.string().min(1),
1385
- agent: z4.string().min(1).optional(),
1386
- mode: z4.string().min(1).optional(),
1387
- messageID: z4.string().min(1).optional(),
1388
- costUSD: z4.number().finite().min(0),
1389
- tokensIn: z4.number().int().min(0),
1390
- tokensOut: z4.number().int().min(0),
1391
- tokensReasoning: z4.number().int().min(0),
1392
- tokensCacheRead: z4.number().int().min(0),
1393
- tokensCacheWrite: z4.number().int().min(0),
1394
- durationMs: z4.number().int().min(0)
1395
- });
1396
- var ToolcallEventSchema = EventBaseSchema.extend({
1397
- type: z4.literal("toolcall"),
1398
- tool: z4.string().min(1),
1399
- callID: z4.string().min(1),
1400
- agent: z4.string().min(1).optional(),
1401
- durationMs: z4.number().int().min(0),
1402
- ok: z4.boolean(),
1403
- title: z4.string().optional()
1404
- });
1405
- var MeetingEventSchema = EventBaseSchema.extend({
1406
- type: z4.literal("meeting"),
1407
- batchID: z4.string().min(1),
1408
- purpose: z4.string().optional(),
1409
- roles: z4.array(z4.string().min(1)),
1410
- decisionIDs: z4.array(z4.string().min(1)).optional()
1411
- });
1412
- var DecisionEventSchema = EventBaseSchema.extend({
1413
- type: z4.literal("decision"),
1414
- agent: z4.string().min(1).optional(),
1415
- summary: z4.string().min(1),
1416
- tags: z4.array(z4.string().min(1)).optional()
1417
- });
1418
- var ActivityEventSchema = EventBaseSchema.extend({
1419
- type: z4.literal("activity"),
1420
- kind: z4.enum(["route", "agent", "decision", "commit"]),
1421
- agent: z4.string().min(1).optional(),
1422
- summary: z4.string().min(1)
1423
- });
1424
- var SessionEndpointEventSchema = EventBaseSchema.extend({
1425
- type: z4.literal("session-endpoint"),
1426
- url: z4.string().url(),
1427
- directory: z4.string().min(1).optional(),
1428
- worktree: z4.string().min(1).optional(),
1429
- title: z4.string().min(1).optional()
1430
- });
1431
- var OpenTeamEventSchema = z4.discriminatedUnion("type", [
1432
- RouteEventSchema,
1433
- MessageEventSchema,
1434
- ToolcallEventSchema,
1435
- MeetingEventSchema,
1436
- DecisionEventSchema,
1437
- ActivityEventSchema,
1438
- SessionEndpointEventSchema
1439
- ]);
1574
+ var ms=parseInt(document.documentElement.getAttribute('data-refresh')||'2000',10);
1575
+ setInterval(function(){reloadIfChanged(current)},isNaN(ms)?2000:Math.max(250,ms));
1440
1576
 
1441
- // src/storage/fsStorageProvider.ts
1442
- import {
1443
- appendFile,
1444
- mkdir,
1445
- readdir,
1446
- readFile,
1447
- rm,
1448
- stat,
1449
- writeFile
1450
- } from "node:fs/promises";
1451
- import { dirname, join } from "node:path";
1452
- function isMissing(error) {
1453
- return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
1454
- }
1455
- function createFsStorageProvider(root) {
1456
- function toDisk(path) {
1457
- return join(root, ...splitVirtualPath(path));
1577
+ function selectTab(name){
1578
+ var tabs=document.querySelectorAll('#tabbar .tab');
1579
+ for(var i=0;i<tabs.length;i++){tabs[i].classList.toggle('active',tabs[i].getAttribute('data-tab')===name)}
1580
+ var views=document.querySelectorAll('main .view');
1581
+ var matched=false;
1582
+ for(var j=0;j<views.length;j++){
1583
+ var show=views[j].getAttribute('data-view')===name;
1584
+ views[j].hidden=!show;
1585
+ if(show){matched=true;if(name!=='all'){connectTerminal(views[j],name)}}
1586
+ }
1587
+ if(!matched){return false}
1588
+ try{sessionStorage.setItem('openteam.tab',name)}catch(e){}
1589
+ return true;
1458
1590
  }
1459
- return {
1460
- async read(path) {
1461
- try {
1462
- return await readFile(toDisk(path), "utf8");
1463
- } catch (error) {
1464
- if (isMissing(error)) {
1465
- return;
1466
- }
1467
- throw error;
1468
- }
1469
- },
1470
- async write(path, content) {
1471
- const disk = toDisk(path);
1472
- await mkdir(dirname(disk), { recursive: true });
1473
- await writeFile(disk, content, "utf8");
1474
- },
1475
- async append(path, content) {
1476
- const disk = toDisk(path);
1477
- await mkdir(dirname(disk), { recursive: true });
1478
- await appendFile(disk, content, "utf8");
1479
- },
1480
- async exists(path) {
1481
- try {
1482
- await stat(toDisk(path));
1483
- return true;
1484
- } catch (error) {
1485
- if (isMissing(error)) {
1486
- return false;
1487
- }
1488
- throw error;
1489
- }
1490
- },
1491
- async list(dir) {
1492
- try {
1493
- const names = await readdir(toDisk(dir));
1494
- return names.sort();
1495
- } catch (error) {
1496
- if (isMissing(error)) {
1497
- return [];
1498
- }
1499
- throw error;
1500
- }
1501
- },
1502
- async isDirectory(path) {
1503
- try {
1504
- return (await stat(toDisk(path))).isDirectory();
1505
- } catch (error) {
1506
- if (isMissing(error)) {
1507
- return false;
1508
- }
1509
- throw error;
1510
- }
1511
- },
1512
- async mkdir(dir) {
1513
- await mkdir(toDisk(dir), { recursive: true });
1514
- },
1515
- async delete(path) {
1516
- await rm(toDisk(path), { force: true });
1517
- },
1518
- async stat(path) {
1519
- try {
1520
- const stats = await stat(toDisk(path));
1521
- return {
1522
- size: stats.size,
1523
- mtimeMs: stats.mtimeMs,
1524
- isDirectory: stats.isDirectory()
1525
- };
1526
- } catch (error) {
1527
- if (isMissing(error)) {
1528
- return;
1529
- }
1530
- throw error;
1531
- }
1591
+ function connectTerminal(view,id){
1592
+ if(view.__wired){return}
1593
+ var root=document.documentElement;
1594
+ var token=root.getAttribute('data-token');
1595
+ if(root.getAttribute('data-terminal')!=='1'||!token){return}
1596
+ var log=view.querySelector('.termlog');
1597
+ if(!log){return}
1598
+ view.__wired=true;
1599
+ function append(cls,text){
1600
+ var line=document.createElement('div');
1601
+ line.className='tl '+cls;line.textContent=text;
1602
+ log.appendChild(line);log.scrollTop=log.scrollHeight;
1532
1603
  }
1533
- };
1534
- }
1535
-
1536
- // src/telemetry/types.ts
1537
- import { z as z5 } from "zod";
1538
- var CostRecordSchema = z5.object({
1539
- ts: z5.number().finite(),
1540
- sessionID: z5.string().min(1).optional(),
1541
- promptHash: z5.string().min(1),
1542
- promptChars: z5.number().int().min(0),
1543
- tier: ComplexityTierSchema,
1544
- routeKind: z5.enum(["local", "frontier"]),
1545
- selected: ModelRefSchema,
1546
- rationale: z5.string(),
1547
- estimatedCostUSD: z5.number().finite().min(0),
1548
- baselineCostUSD: z5.number().finite().min(0),
1549
- estimatedSavingsUSD: z5.number().finite(),
1550
- budgetAction: z5.string().min(1),
1551
- tokensIn: z5.number().int().min(0).optional(),
1552
- tokensOut: z5.number().int().min(0).optional()
1553
- });
1554
-
1555
- // src/telemetry/read.ts
1556
- var DEFAULT_TELEMETRY_PATH = ".opencode/openteam-telemetry.jsonl";
1557
- function parseCostRecordsJsonl(text) {
1558
- const records = [];
1559
- for (const line of text.split(`
1560
- `)) {
1561
- const trimmed = line.trim();
1562
- if (trimmed.length === 0) {
1563
- continue;
1604
+ var q='/console/session/'+encodeURIComponent(id);
1605
+ var tk='token='+encodeURIComponent(token);
1606
+ if('EventSource' in window){
1607
+ try{
1608
+ var es=new EventSource(q+'/stream?'+tk);
1609
+ es.addEventListener('frame',function(e){try{var f=JSON.parse(e.data);append(f.kind,f.text)}catch(_){}});
1610
+ es.onerror=function(){/* reconecta solo */};
1611
+ }catch(_){/* ignore */}
1564
1612
  }
1565
- let candidate;
1566
- try {
1567
- candidate = JSON.parse(trimmed);
1568
- } catch {
1569
- continue;
1613
+ var form=view.querySelector('form.terminput');
1614
+ if(form){
1615
+ form.addEventListener('submit',function(e){
1616
+ e.preventDefault();
1617
+ var inp=form.querySelector('input[name=text]');
1618
+ var text=inp.value;
1619
+ if(!text){return}
1620
+ inp.value='';append('input','› '+text);
1621
+ fetch(q+'/input?'+tk,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({text:text})})
1622
+ .then(function(r){if(!r.ok){append('error','✖ envío rechazado ('+r.status+')')}})
1623
+ .catch(function(){append('error','✖ no se pudo enviar')});
1624
+ });
1570
1625
  }
1571
- const parsed = CostRecordSchema.safeParse(candidate);
1572
- if (parsed.success) {
1573
- records.push(parsed.data);
1626
+ connectPty(view,id,q,tk);
1627
+ }
1628
+ function connectPty(view,id,q,tk){
1629
+ var root=document.documentElement;
1630
+ if(root.getAttribute('data-pty')!=='1'){return}
1631
+ var pane=view.querySelector('.pty');
1632
+ if(!pane){return}
1633
+ var log=pane.querySelector('.ptylog');
1634
+ var startBtn=pane.querySelector('.pty-start');
1635
+ var input=pane.querySelector('form.ptyinput');
1636
+ function plog(text){
1637
+ var d=document.createElement('span');d.textContent=text;
1638
+ log.appendChild(d);log.scrollTop=log.scrollHeight;
1639
+ }
1640
+ if(!startBtn){return}
1641
+ startBtn.addEventListener('click',function(){
1642
+ startBtn.disabled=true;plog('Abriendo shell…
1643
+ ');
1644
+ fetch(q+'/pty?'+tk,{method:'POST',headers:{'content-type':'application/json'},body:'{}'})
1645
+ .then(function(r){return r.ok?r.json():Promise.reject(r.status)})
1646
+ .then(function(info){openPtyWs(info.wsUrl)})
1647
+ .catch(function(s){plog('✖ no se pudo abrir PTY ('+s+')
1648
+ ');startBtn.disabled=false});
1649
+ });
1650
+ function openPtyWs(url){
1651
+ try{
1652
+ var ws=new WebSocket(url);
1653
+ ws.binaryType='arraybuffer';
1654
+ ws.onmessage=function(e){
1655
+ var data=e.data;
1656
+ if(data instanceof ArrayBuffer){data=new TextDecoder().decode(new Uint8Array(data))}
1657
+ plog(data);
1658
+ };
1659
+ ws.onclose=function(){plog('
1660
+ [shell cerrada]
1661
+ ');startBtn.disabled=false};
1662
+ ws.onerror=function(){plog('
1663
+ ✖ error de WebSocket
1664
+ ')};
1665
+ if(input){
1666
+ input.hidden=false;
1667
+ input.addEventListener('submit',function(e){
1668
+ e.preventDefault();
1669
+ var c=input.querySelector('input[name=cmd]');
1670
+ if(!c.value){return}
1671
+ ws.send(c.value+'\r');c.value='';
1672
+ });
1673
+ }
1674
+ }catch(_){plog('
1675
+ ✖ WebSocket no disponible
1676
+ ');startBtn.disabled=false}
1574
1677
  }
1575
1678
  }
1576
- return records;
1679
+ var bar=document.getElementById('tabbar');
1680
+ if(bar){
1681
+ bar.addEventListener('click',function(e){
1682
+ var t=e.target;
1683
+ while(t&&t!==bar&&!t.getAttribute('data-tab')){t=t.parentNode}
1684
+ if(t&&t.getAttribute('data-tab')){selectTab(t.getAttribute('data-tab'))}
1685
+ });
1686
+ }
1687
+ var saved='all';
1688
+ try{saved=sessionStorage.getItem('openteam.tab')||'all'}catch(e){}
1689
+ if(!selectTab(saved)){selectTab('all')}
1690
+ })();
1691
+ `;
1692
+ function renderConsoleHtml(state, options = {}) {
1693
+ const refreshMs = options.refreshMs ?? 2000;
1694
+ const terminalEnabled = options.terminalEnabled === true;
1695
+ const ptyEnabled = options.ptyEnabled === true;
1696
+ const allBody = [
1697
+ summaryPanel(state.cost),
1698
+ totalsPanel(state.totals),
1699
+ sessionsPanel(state.sessions),
1700
+ loopPanel(state.loop),
1701
+ teamPanel(state.team),
1702
+ tierPanel(state.cost),
1703
+ routesPanel(state.recentRoutes),
1704
+ decisionsPanel(state.decisions),
1705
+ meetingsPanel(state.meetings),
1706
+ activityPanel(state.activity)
1707
+ ].join("");
1708
+ const allView = `<div class="view" data-view="all">${allBody}</div>`;
1709
+ const sessionViews = state.sessionsDetail.map((tab) => sessionView(tab, terminalEnabled, ptyEnabled)).join("");
1710
+ const body = allView + sessionViews;
1711
+ const session = state.session.id === undefined ? "" : ` · sesión ${escapeHtml(state.session.id)}`;
1712
+ const tokenAttr = terminalEnabled && options.consoleToken !== undefined ? ` data-token="${escapeHtml(options.consoleToken)}" data-terminal="1"${ptyEnabled ? ' data-pty="1"' : ""}` : "";
1713
+ return [
1714
+ "<!doctype html>",
1715
+ `<html lang="es" data-generated="${escapeHtml(state.generatedAt)}" data-refresh="${refreshMs}"${tokenAttr}>`,
1716
+ "<head>",
1717
+ '<meta charset="utf-8">',
1718
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
1719
+ "<title>openteam console</title>",
1720
+ `<style>${STYLE}</style>`,
1721
+ "</head>",
1722
+ "<body>",
1723
+ "<header>",
1724
+ "<h1>openteam console</h1>",
1725
+ `<span class="meta">actualizado ${escapeHtml(state.generatedAt)}${session}</span>`,
1726
+ "</header>",
1727
+ tabBar(state.sessionsDetail),
1728
+ `<main>${body}</main>`,
1729
+ `<script>${CLIENT_JS}</script>`,
1730
+ "</body>",
1731
+ "</html>"
1732
+ ].join("");
1577
1733
  }
1578
1734
 
1579
- // src/telemetry/eventLog.ts
1580
- var DEFAULT_SESSIONS_DIR = ".opencode/openteam/sessions";
1581
- var LEGACY_SESSION_ID = "legacy";
1582
- function parseEventsJsonl(text) {
1583
- const events = [];
1584
- for (const line of text.split(`
1585
- `)) {
1586
- const trimmed = line.trim();
1587
- if (trimmed.length === 0) {
1588
- continue;
1589
- }
1590
- let candidate;
1591
- try {
1592
- candidate = JSON.parse(trimmed);
1593
- } catch {
1594
- continue;
1595
- }
1596
- const parsed = OpenTeamEventSchema.safeParse(candidate);
1597
- if (parsed.success) {
1598
- events.push(parsed.data);
1599
- }
1600
- }
1601
- return events;
1602
- }
1603
- function costRecordToRouteEvent(record) {
1604
- const event = {
1605
- v: EVENT_SCHEMA_VERSION,
1606
- type: "route",
1607
- ts: record.ts,
1608
- sessionID: record.sessionID ?? LEGACY_SESSION_ID,
1609
- promptHash: record.promptHash,
1610
- promptChars: record.promptChars,
1611
- tier: record.tier,
1612
- routeKind: record.routeKind,
1613
- selected: record.selected,
1614
- rationale: record.rationale,
1615
- estimatedCostUSD: record.estimatedCostUSD,
1616
- baselineCostUSD: record.baselineCostUSD,
1617
- estimatedSavingsUSD: record.estimatedSavingsUSD,
1618
- budgetAction: record.budgetAction
1735
+ // src/commands/report.ts
1736
+ var TIERS2 = [
1737
+ "trivial",
1738
+ "simple",
1739
+ "moderate",
1740
+ "hard"
1741
+ ];
1742
+ function emptyTierSummary() {
1743
+ return {
1744
+ trivial: { count: 0, estimatedUSD: 0, savingsUSD: 0 },
1745
+ simple: { count: 0, estimatedUSD: 0, savingsUSD: 0 },
1746
+ moderate: { count: 0, estimatedUSD: 0, savingsUSD: 0 },
1747
+ hard: { count: 0, estimatedUSD: 0, savingsUSD: 0 }
1619
1748
  };
1620
- if (record.tokensIn !== undefined) {
1621
- event.tokensIn = record.tokensIn;
1622
- }
1623
- if (record.tokensOut !== undefined) {
1624
- event.tokensOut = record.tokensOut;
1625
- }
1626
- return event;
1627
1749
  }
1628
- function routeEventToCostRecord(event) {
1629
- const record = {
1630
- ts: event.ts,
1631
- sessionID: event.sessionID,
1632
- promptHash: event.promptHash,
1633
- promptChars: event.promptChars,
1634
- tier: event.tier,
1635
- routeKind: event.routeKind,
1636
- selected: event.selected,
1637
- rationale: event.rationale,
1638
- estimatedCostUSD: event.estimatedCostUSD,
1639
- baselineCostUSD: event.baselineCostUSD,
1640
- estimatedSavingsUSD: event.estimatedSavingsUSD,
1641
- budgetAction: event.budgetAction
1750
+ function summarizeCostRecords(records) {
1751
+ const report = {
1752
+ count: 0,
1753
+ localCount: 0,
1754
+ frontierCount: 0,
1755
+ totalEstimatedUSD: 0,
1756
+ totalBaselineUSD: 0,
1757
+ totalSavingsUSD: 0,
1758
+ savingsPct: 0,
1759
+ tokensIn: 0,
1760
+ tokensOut: 0,
1761
+ byTier: emptyTierSummary()
1642
1762
  };
1643
- if (event.tokensIn !== undefined) {
1644
- record.tokensIn = event.tokensIn;
1645
- }
1646
- if (event.tokensOut !== undefined) {
1647
- record.tokensOut = event.tokensOut;
1648
- }
1649
- return record;
1650
- }
1651
- async function readRouteCostRecords(dir, deps) {
1652
- const events = await readSessionEvents(dir, deps);
1653
- const records = [];
1654
- for (const event of events) {
1655
- if (event.type === "route") {
1656
- records.push(routeEventToCostRecord(event));
1763
+ for (const record of records) {
1764
+ report.count += 1;
1765
+ if (record.routeKind === "local") {
1766
+ report.localCount += 1;
1767
+ } else {
1768
+ report.frontierCount += 1;
1657
1769
  }
1770
+ report.totalEstimatedUSD += record.estimatedCostUSD;
1771
+ report.totalBaselineUSD += record.baselineCostUSD;
1772
+ report.totalSavingsUSD += record.estimatedSavingsUSD;
1773
+ report.tokensIn += record.tokensIn ?? 0;
1774
+ report.tokensOut += record.tokensOut ?? 0;
1775
+ const tier = report.byTier[record.tier];
1776
+ tier.count += 1;
1777
+ tier.estimatedUSD += record.estimatedCostUSD;
1778
+ tier.savingsUSD += record.estimatedSavingsUSD;
1658
1779
  }
1659
- return records;
1780
+ report.savingsPct = report.totalBaselineUSD > 0 ? report.totalSavingsUSD / report.totalBaselineUSD * 100 : 0;
1781
+ return report;
1660
1782
  }
1661
- async function readSessionEvents(dir, deps) {
1662
- const files = (await deps.storage.list(dir)).filter((file) => file.endsWith(".jsonl"));
1663
- const perFile = await Promise.all(files.map(async (file) => {
1664
- const text = await deps.storage.read(joinVirtualPath(dir, file));
1665
- return text === undefined ? [] : parseEventsJsonl(text);
1666
- }));
1667
- const events = perFile.flat();
1668
- if (deps.legacyTelemetryPath !== undefined) {
1669
- const legacyText = await deps.storage.read(deps.legacyTelemetryPath);
1670
- if (legacyText !== undefined) {
1671
- for (const record of parseCostRecordsJsonl(legacyText)) {
1672
- events.push(costRecordToRouteEvent(record));
1673
- }
1674
- }
1783
+ function usd2(value) {
1784
+ return `$${value.toFixed(5)}`;
1785
+ }
1786
+ function renderReport(report) {
1787
+ if (report.count === 0) {
1788
+ return "openteam report: sin registros de telemetría todavía.";
1675
1789
  }
1676
- return events.sort((a, b) => a.ts - b.ts);
1790
+ const lines = [
1791
+ "openteam report:",
1792
+ ` decisiones: ${report.count} (local ${report.localCount} · frontier ${report.frontierCount})`,
1793
+ ` coste real: ${usd2(report.totalEstimatedUSD)}`,
1794
+ ` baseline: ${usd2(report.totalBaselineUSD)}`,
1795
+ ` ahorro: ${usd2(report.totalSavingsUSD)} (${report.savingsPct.toFixed(2)}%)`,
1796
+ ` tokens: in ${report.tokensIn} · out ${report.tokensOut}`,
1797
+ " por tier:"
1798
+ ];
1799
+ for (const tier of TIERS2) {
1800
+ const summary = report.byTier[tier];
1801
+ lines.push(` ${tier.padEnd(9)} ${summary.count} · coste ${usd2(summary.estimatedUSD)} · ahorro ${usd2(summary.savingsUSD)}`);
1802
+ }
1803
+ return lines.join(`
1804
+ `);
1677
1805
  }
1678
1806
 
1679
1807
  // src/console/backlog.ts
@@ -2053,13 +2181,177 @@ async function handleConsoleRoute(request, response, url, deps) {
2053
2181
  return true;
2054
2182
  }
2055
2183
 
2184
+ // src/web/storageRoute.ts
2185
+ var JSON_HEADERS2 = { "content-type": "application/json" };
2186
+ var READ_OPS = new Set(["read", "exists", "list", "isDirectory", "stat"]);
2187
+ var WRITE_OPS = new Set(["write", "append", "mkdir", "delete"]);
2188
+ function bearerFromHeader2(request) {
2189
+ const header = request.headers.authorization;
2190
+ if (typeof header !== "string" || !header.startsWith("Bearer ")) {
2191
+ return;
2192
+ }
2193
+ const value = header.slice("Bearer ".length).trim();
2194
+ return value.length === 0 ? undefined : value;
2195
+ }
2196
+ function authorized2(request, query, token) {
2197
+ const provided = tokenFromQuery(query) ?? bearerFromHeader2(request);
2198
+ return provided !== undefined && safeEqualToken(provided, token);
2199
+ }
2200
+ function pathAllowed(path, allowPrefixes) {
2201
+ if (allowPrefixes === undefined) {
2202
+ return true;
2203
+ }
2204
+ return allowPrefixes.some((rawPrefix) => {
2205
+ const prefix = normalizeVirtualPath(rawPrefix);
2206
+ if (prefix === "") {
2207
+ return true;
2208
+ }
2209
+ return path === prefix || path.startsWith(`${prefix}/`);
2210
+ });
2211
+ }
2212
+ async function readJsonBody2(request, limit) {
2213
+ return new Promise((resolve) => {
2214
+ let raw = "";
2215
+ let aborted = false;
2216
+ request.on("data", (chunk) => {
2217
+ if (aborted) {
2218
+ return;
2219
+ }
2220
+ raw += chunk.toString("utf8");
2221
+ if (raw.length > limit) {
2222
+ aborted = true;
2223
+ resolve(undefined);
2224
+ }
2225
+ });
2226
+ request.on("end", () => {
2227
+ if (!aborted) {
2228
+ try {
2229
+ resolve(JSON.parse(raw));
2230
+ } catch {
2231
+ resolve(undefined);
2232
+ }
2233
+ }
2234
+ });
2235
+ request.on("error", () => resolve(undefined));
2236
+ });
2237
+ }
2238
+ function sendJson(response, status, body) {
2239
+ response.writeHead(status, JSON_HEADERS2).end(JSON.stringify(body));
2240
+ }
2241
+ async function handleRead(response, op, path, storage) {
2242
+ if (op === "read") {
2243
+ sendJson(response, 200, { content: await storage.read(path) ?? null });
2244
+ return;
2245
+ }
2246
+ if (op === "list") {
2247
+ sendJson(response, 200, { entries: await storage.list(path) });
2248
+ return;
2249
+ }
2250
+ if (op === "exists") {
2251
+ sendJson(response, 200, { value: await storage.exists(path) });
2252
+ return;
2253
+ }
2254
+ if (op === "isDirectory") {
2255
+ sendJson(response, 200, { value: await storage.isDirectory(path) });
2256
+ return;
2257
+ }
2258
+ sendJson(response, 200, { stat: await storage.stat(path) ?? null });
2259
+ }
2260
+ async function handleWrite(request, response, op, deps) {
2261
+ if (deps.readOnly === true) {
2262
+ sendJson(response, 403, { error: "read-only" });
2263
+ return;
2264
+ }
2265
+ const body = await readJsonBody2(request, 8 * 1024 * 1024);
2266
+ const record = typeof body === "object" && body !== null ? body : undefined;
2267
+ const rawPath = record?.path;
2268
+ if (typeof rawPath !== "string") {
2269
+ sendJson(response, 400, { error: "invalid path" });
2270
+ return;
2271
+ }
2272
+ let path;
2273
+ try {
2274
+ path = normalizeVirtualPath(rawPath);
2275
+ } catch (error) {
2276
+ if (error instanceof StoragePathError) {
2277
+ sendJson(response, 400, { error: "invalid path" });
2278
+ return;
2279
+ }
2280
+ throw error;
2281
+ }
2282
+ if (!pathAllowed(path, deps.allowPrefixes)) {
2283
+ sendJson(response, 403, { error: "path not allowed" });
2284
+ return;
2285
+ }
2286
+ if (op === "write" || op === "append") {
2287
+ const content = record?.content;
2288
+ if (typeof content !== "string") {
2289
+ sendJson(response, 400, { error: "invalid content" });
2290
+ return;
2291
+ }
2292
+ if (op === "write") {
2293
+ await deps.storage.write(path, content);
2294
+ } else {
2295
+ await deps.storage.append(path, content);
2296
+ }
2297
+ } else if (op === "mkdir") {
2298
+ await deps.storage.mkdir(path);
2299
+ } else {
2300
+ await deps.storage.delete(path);
2301
+ }
2302
+ sendJson(response, 200, { ok: true });
2303
+ }
2304
+ async function handleStorageRoute(request, response, url, deps) {
2305
+ if (!url.pathname.startsWith("/storage/")) {
2306
+ return false;
2307
+ }
2308
+ const op = url.pathname.slice("/storage/".length);
2309
+ if (!READ_OPS.has(op) && !WRITE_OPS.has(op)) {
2310
+ sendJson(response, 404, { error: "not found" });
2311
+ return true;
2312
+ }
2313
+ if (!authorized2(request, url.searchParams, deps.token)) {
2314
+ sendJson(response, 401, { error: "unauthorized" });
2315
+ return true;
2316
+ }
2317
+ if (READ_OPS.has(op)) {
2318
+ if (request.method !== "GET") {
2319
+ sendJson(response, 405, { error: "method not allowed" });
2320
+ return true;
2321
+ }
2322
+ const rawPath = url.searchParams.get("path") ?? "";
2323
+ let path;
2324
+ try {
2325
+ path = normalizeVirtualPath(rawPath);
2326
+ } catch (error) {
2327
+ if (error instanceof StoragePathError) {
2328
+ sendJson(response, 400, { error: "invalid path" });
2329
+ return true;
2330
+ }
2331
+ throw error;
2332
+ }
2333
+ if (!pathAllowed(path, deps.allowPrefixes)) {
2334
+ sendJson(response, 403, { error: "path not allowed" });
2335
+ return true;
2336
+ }
2337
+ await handleRead(response, op, path, deps.storage);
2338
+ return true;
2339
+ }
2340
+ if (request.method !== "POST") {
2341
+ sendJson(response, 405, { error: "method not allowed" });
2342
+ return true;
2343
+ }
2344
+ await handleWrite(request, response, op, deps);
2345
+ return true;
2346
+ }
2347
+
2056
2348
  // src/web/server.ts
2057
2349
  var SSE_HEADERS2 = {
2058
2350
  "content-type": "text/event-stream",
2059
2351
  "cache-control": "no-cache",
2060
2352
  connection: "keep-alive"
2061
2353
  };
2062
- var JSON_HEADERS2 = { "content-type": "application/json" };
2354
+ var JSON_HEADERS3 = { "content-type": "application/json" };
2063
2355
  var HTML_HEADERS = { "content-type": "text/html; charset=utf-8" };
2064
2356
  function isAddressInUse(error) {
2065
2357
  return typeof error === "object" && error !== null && "code" in error && error.code === "EADDRINUSE";
@@ -2123,6 +2415,9 @@ async function createConsoleServer(deps) {
2123
2415
  if (consoleDeps !== undefined && await handleConsoleRoute(request, response, url2, consoleDeps)) {
2124
2416
  return;
2125
2417
  }
2418
+ if (deps.storageApi !== undefined && await handleStorageRoute(request, response, url2, deps.storageApi)) {
2419
+ return;
2420
+ }
2126
2421
  if (request.method !== "GET") {
2127
2422
  response.writeHead(405).end("method not allowed");
2128
2423
  return;
@@ -2133,11 +2428,11 @@ async function createConsoleServer(deps) {
2133
2428
  return;
2134
2429
  }
2135
2430
  if (path === "/api/state") {
2136
- response.writeHead(200, JSON_HEADERS2).end(await stateJson(deps));
2431
+ response.writeHead(200, JSON_HEADERS3).end(await stateJson(deps));
2137
2432
  return;
2138
2433
  }
2139
2434
  if (path === "/healthz") {
2140
- response.writeHead(200, JSON_HEADERS2).end(JSON.stringify({ ok: true }));
2435
+ response.writeHead(200, JSON_HEADERS3).end(JSON.stringify({ ok: true }));
2141
2436
  return;
2142
2437
  }
2143
2438
  if (path === "/favicon.ico") {
@@ -2518,7 +2813,8 @@ async function createConsoleRuntime(deps) {
2518
2813
  readSnapshot,
2519
2814
  config: deps.config,
2520
2815
  ...deps.log !== undefined ? { log: deps.log } : {},
2521
- ...deps.console !== undefined ? { console: deps.console } : {}
2816
+ ...deps.console !== undefined ? { console: deps.console } : {},
2817
+ ...deps.storageApi !== undefined ? { storageApi: deps.storageApi } : {}
2522
2818
  });
2523
2819
  const watchFn = deps.watch ?? ((path, listener) => fsWatch(path, { persistent: false }, listener));
2524
2820
  const watcher = watchSources([deps.sessionsDir, decisionsPath, backlogPath, deps.agentDir], () => {
@@ -2576,6 +2872,20 @@ async function runConsoleServe(deps, options) {
2576
2872
  createClient: makeClient,
2577
2873
  createPtyClient: makePtyClient
2578
2874
  },
2875
+ ...config.console.remoteStorage ? {
2876
+ storageApi: {
2877
+ storage: deps.storage,
2878
+ token,
2879
+ readOnly: true,
2880
+ allowPrefixes: [
2881
+ deps.sessionsDir,
2882
+ deps.decisionsPath,
2883
+ deps.backlogPath,
2884
+ deps.agentDir,
2885
+ ...deps.legacyTelemetryPath !== undefined ? [deps.legacyTelemetryPath] : []
2886
+ ]
2887
+ }
2888
+ } : {},
2579
2889
  log: deps.log
2580
2890
  });
2581
2891
  } catch (error) {
@@ -2584,6 +2894,23 @@ async function runConsoleServe(deps, options) {
2584
2894
  return { exitCode: 1 };
2585
2895
  }
2586
2896
  deps.log("Escuchando en loopback. Pulsa Ctrl+C para parar.");
2897
+ if (config.console.remoteStorage) {
2898
+ deps.log("Storage remoto expuesto en /storage/* (read-only, requiere token).");
2899
+ }
2900
+ if (config.storage.sqliteIndex.enabled) {
2901
+ const buildIndex = deps.buildSqliteIndex ?? buildSqliteIndexFromConfig;
2902
+ try {
2903
+ const result = await buildIndex(config.storage.sqliteIndex.path, deps.sessionsDir, {
2904
+ storage: deps.storage,
2905
+ ...deps.legacyTelemetryPath !== undefined ? { legacyTelemetryPath: deps.legacyTelemetryPath } : {}
2906
+ });
2907
+ result.index.close();
2908
+ deps.log(`Índice SQLite reconstruido (${result.events} eventos) en ${config.storage.sqliteIndex.path}.`);
2909
+ } catch (error) {
2910
+ const message = error instanceof Error ? error.message : String(error);
2911
+ deps.log(`Índice SQLite deshabilitado (no disponible): ${message}`);
2912
+ }
2913
+ }
2587
2914
  if (options.open && deps.openBrowser !== undefined) {
2588
2915
  try {
2589
2916
  await deps.openBrowser(runtime.url);