feinai 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
5
5
 
6
+ ## [0.8.3] - 2026-09-02
7
+
8
+ ### Added
9
+
10
+ - **Contador de agentes activos en el navbar.** Pill junto a los stats del header (`#agents-pill`), visible en cualquier ruta gracias a un poll global independiente (`pollAgentsGlobal`, cada 5s) que no se detiene al navegar entre páginas. Muestra el total de procesos matcheados y, en el tooltip, cuántos están verificados contra el worktree real vs. sólo matcheados por id de tarea.
11
+
12
+ ### Fixed
13
+
14
+ - **El contador de tiempo transcurrido de una tarea siempre arrancaba en ~120m.** `datetime('now')` de SQLite devuelve timestamps UTC sin marcador de zona (`"YYYY-MM-DD HH:MM:SS"`); `new Date(...)` los parseaba como hora local, corriendo todos los cálculos de tiempo por el offset UTC de la máquina (2h en este caso). Se agregó `parseServerDate()`, que interpreta cualquier timestamp sin zona explícita como UTC, y se usa ahora en `relativeTime`, `elapsedSince` y en el umbral de color de `tickElapsed`.
15
+
16
+ ### Changed
17
+
18
+ - **Auditoría del detector de "agentes activos" (`src/agents-status.ts`).** El matching anterior era: grep de `ps` machine-wide por substring `TASK-...` (sin scope de proyecto), sin verificar que el proceso realmente corriera en el worktree de esa tarea, y sólo tomaba el primer proceso matcheado por tarea (`.find`), descartando el resto en silencio. Ahora `listAgentProcesses` recibe los pares `{taskId, worktree}` de las tareas `in_progress` (vía `/api/agents` → `listTasks(db, { status: "in_progress" })`), verifica cada match leyendo `/proc/:pid/cwd` contra el worktree esperado, agrega todos los procesos que matchean una misma tarea (no sólo el primero) y expone un flag `verified` por proceso. El dashboard ahora muestra cpu/mem agregados, la cantidad de procesos, un badge "unverified" cuando ningún match fue confirmado por cwd, y un badge "no process detected" cuando una tarea `in_progress` no tiene ningún proceso asociado (agente huérfano/caído).
19
+ - Auditado el criterio de creación de logs de requests (`logRequest` en `src/server.ts`): confirmado correcto — suprime cualquier GET del actor `dashboard` (evita el loop de refresh ya arreglado en 0.8.2) y sigue logueando toda mutación y todo request no-dashboard; no se encontraron imprecisiones adicionales.
20
+
6
21
  ## [0.8.2] - 2026-09-02
7
22
 
8
23
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feinai",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "Task & spec manager for AI agents — parallel worktrees, live dashboard, SDD skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,26 +1,79 @@
1
+ import { readlink } from "node:fs/promises";
2
+
1
3
  export interface AgentProcess {
2
4
  pid: number;
3
5
  cpu: number;
4
6
  mem: number;
5
7
  command: string;
6
8
  taskId: string | null;
9
+ /** true if the process's cwd was confirmed inside the task's worktree */
10
+ verified: boolean;
11
+ }
12
+
13
+ export interface ExpectedAgent {
14
+ taskId: string;
15
+ worktree: string | null;
7
16
  }
8
17
 
9
- export async function listAgentProcesses(): Promise<AgentProcess[]> {
18
+ /**
19
+ * Snapshot of processes that look like they're working on active tasks.
20
+ *
21
+ * Matching is done two ways:
22
+ * 1. By worktree cwd (`/proc/:pid/cwd`) against each expected task's worktree
23
+ * path — this is the precise signal and sets `verified: true`.
24
+ * 2. By a `TASK-...` substring in the command line — kept as a fallback so we
25
+ * don't lose visibility into agents whose cwd we can't resolve (e.g. the
26
+ * process already exited, or /proc isn't readable), but reported as
27
+ * `verified: false` since a substring match can be coincidental or belong
28
+ * to an unrelated project using the same task ID scheme.
29
+ */
30
+ export async function listAgentProcesses(
31
+ expected: ExpectedAgent[] = [],
32
+ ): Promise<AgentProcess[]> {
33
+ let lines: string[];
10
34
  try {
11
- const result = await Bun.$`ps -eo pid,pcpu,pmem,command | grep -E 'TASK-[A-Z0-9-]+' | grep -v grep`.text();
12
- const lines = result.trim().split("\n").filter(Boolean);
13
- return lines.map((line) => {
14
- const parts = line.trim().split(/\s+/);
15
- const pid = parseInt(parts[0]!, 10);
16
- const cpu = parseFloat(parts[1]!);
17
- const mem = parseFloat(parts[2]!);
18
- const command = parts.slice(3).join(" ");
19
- const taskMatch = command.match(/TASK-[A-Z0-9-]+/);
20
- const taskId = taskMatch ? taskMatch[0] : null;
21
- return { pid, cpu, mem, command, taskId };
22
- }).filter((p) => Number.isFinite(p.pid));
35
+ const result = await Bun.$`ps -eo pid,pcpu,pmem,command`.text();
36
+ lines = result.trim().split("\n").slice(1).filter(Boolean);
23
37
  } catch {
24
38
  return [];
25
39
  }
40
+
41
+ const worktreeByTask = new Map(
42
+ expected
43
+ .filter((e): e is ExpectedAgent & { worktree: string } => !!e.worktree)
44
+ .map((e) => [e.taskId, e.worktree]),
45
+ );
46
+
47
+ const candidates: Omit<AgentProcess, "verified">[] = [];
48
+ for (const line of lines) {
49
+ const parts = line.trim().split(/\s+/);
50
+ if (parts.length < 4) continue;
51
+ const pid = parseInt(parts[0]!, 10);
52
+ const cpu = parseFloat(parts[1]!);
53
+ const mem = parseFloat(parts[2]!);
54
+ const command = parts.slice(3).join(" ");
55
+ if (!Number.isFinite(pid)) continue;
56
+ const taskMatch = command.match(/TASK-[A-Z0-9-]+/);
57
+ const taskId = taskMatch ? taskMatch[0] : null;
58
+ if (!taskId) continue;
59
+ candidates.push({ pid, cpu, mem, command, taskId });
60
+ }
61
+
62
+ const results = await Promise.all(
63
+ candidates.map(async (c) => {
64
+ const worktree = c.taskId ? worktreeByTask.get(c.taskId) : undefined;
65
+ let verified = false;
66
+ if (worktree) {
67
+ try {
68
+ const cwd = await readlink(`/proc/${c.pid}/cwd`);
69
+ verified = cwd === worktree || cwd.startsWith(worktree + "/");
70
+ } catch {
71
+ verified = false;
72
+ }
73
+ }
74
+ return { ...c, verified };
75
+ }),
76
+ );
77
+
78
+ return results;
26
79
  }
package/src/cli.ts CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  type OrphanKillResult,
58
58
  } from "./server-state";
59
59
 
60
- const VERSION = "0.8.2";
60
+ const VERSION = "0.8.3";
61
61
 
62
62
  interface ParsedArgs {
63
63
  positional: string[];
@@ -106,6 +106,9 @@
106
106
  .stat.failed .dot { background: var(--red); }
107
107
  .stat .count { font-weight: 600; }
108
108
  .stat .label { color: var(--fg-dim); transition: color 0.1s; }
109
+ .stat.agents .dot { background: var(--gray); }
110
+ .stat.agents.live .dot { background: var(--green); animation: ripple 1.5s ease-out infinite; }
111
+ .stat.agents .count[title] { cursor: help; }
109
112
 
110
113
  .header-actions { display: flex; gap: 8px; align-items: center; }
111
114
  .search-wrap { position: relative; }
@@ -591,6 +594,7 @@
591
594
  feinai
592
595
  </a>
593
596
  <div class="stats" id="stats"></div>
597
+ <button class="stat agents" id="agents-pill" data-goto="#/" title="Active agent processes"></button>
594
598
  <div class="header-actions">
595
599
  <div class="search-wrap">
596
600
  <input class="search-box" id="search" placeholder="Search specs and tasks" autocomplete="off" aria-label="Search specs and tasks">
@@ -664,17 +668,33 @@
664
668
  ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
665
669
  }
666
670
 
671
+ // SQLite's datetime('now') returns "YYYY-MM-DD HH:MM:SS" in UTC with no
672
+ // timezone marker. Handed to `new Date()` as-is, the browser parses it as
673
+ // LOCAL time instead — silently shifting every server timestamp by the
674
+ // local UTC offset (e.g. a freshly claimed task's elapsed counter always
675
+ // "starting" at ~2h in a UTC+2 timezone). Treat any zone-less timestamp
676
+ // from the server as UTC.
677
+ function parseServerDate(s) {
678
+ if (!s) return null;
679
+ const hasZone = /Z$|[+-]\d\d:?\d\d$/.test(s);
680
+ const d = new Date(hasZone ? s : s.replace(" ", "T") + "Z");
681
+ return Number.isNaN(d.getTime()) ? null : d;
682
+ }
683
+
667
684
  function relativeTime(dateStr) {
668
- const diff = Date.now() - new Date(dateStr).getTime();
685
+ const d = parseServerDate(dateStr);
686
+ if (!d) return "";
687
+ const diff = Date.now() - d.getTime();
669
688
  if (diff < 60000) return "just now";
670
689
  if (diff < 3600000) return Math.floor(diff / 60000) + "m ago";
671
690
  if (diff < 86400000) return Math.floor(diff / 3600000) + "h ago";
672
- return new Date(dateStr).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
691
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
673
692
  }
674
693
 
675
694
  function elapsedSince(iso) {
676
- if (!iso) return "";
677
- const ms = Date.now() - new Date(iso).getTime();
695
+ const d = parseServerDate(iso);
696
+ if (!d) return "";
697
+ const ms = Date.now() - d.getTime();
678
698
  if (ms < 0) return "0s";
679
699
  const m = Math.floor(ms / 60000);
680
700
  const s = Math.floor((ms % 60000) / 1000);
@@ -1604,21 +1624,22 @@
1604
1624
  const iso = el.getAttribute("data-elapsed");
1605
1625
  if (!iso) { el.textContent = ""; return; }
1606
1626
  el.textContent = elapsedSince(iso);
1607
- const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
1627
+ const d = parseServerDate(iso);
1628
+ const minutes = d ? Math.floor((Date.now() - d.getTime()) / 60000) : 0;
1608
1629
  el.style.color = minutes >= 60 ? "var(--red)" : minutes >= 30 ? "var(--yellow)" : "var(--fg-dim)";
1609
1630
  });
1610
1631
  }
1611
1632
 
1612
1633
  async function pollWorktreeStatus(ids) {
1613
1634
  if (!ids.length) return;
1614
- const [results, agents] = await Promise.all([
1615
- Promise.allSettled(ids.map(async (id) => ({
1616
- id,
1617
- data: await api("GET", `/api/tasks/${encodeURIComponent(id)}/worktree-status`),
1618
- }))),
1619
- api("GET", "/api/agents").catch(() => []),
1620
- ]);
1621
- agentProcesses = Array.isArray(agents) ? agents : [];
1635
+ // Agent process data comes from the global agentsPillTimer poll
1636
+ // (pollAgentsGlobal) rather than being re-fetched here, so the navbar
1637
+ // pill and the worktree cards always agree and we don't double the
1638
+ // `ps` calls every 5s.
1639
+ const results = await Promise.allSettled(ids.map(async (id) => ({
1640
+ id,
1641
+ data: await api("GET", `/api/tasks/${encodeURIComponent(id)}/worktree-status`),
1642
+ })));
1622
1643
  for (const r of results) if (r.status === "fulfilled") updateWorktreeCard(r.value.id, r.value.data);
1623
1644
  }
1624
1645
 
@@ -1643,7 +1664,7 @@
1643
1664
  const card = badgeEl.closest(".worktree-card");
1644
1665
  if (card) card.dataset.cardState = state.cls;
1645
1666
 
1646
- const agent = agentProcesses.find((a) => a.taskId === id);
1667
+ const matches = agentProcesses.filter((a) => a.taskId === id);
1647
1668
  if (processEl) {
1648
1669
  const ownerEl = card?.querySelector(".owner");
1649
1670
  const ownerType = ownerEl?.dataset.ownerType ?? null;
@@ -1651,9 +1672,19 @@
1651
1672
  const ownerTag = ownerType && ownerPid
1652
1673
  ? `<span>${escapeHtml(ownerType)}</span><span>pid ${escapeHtml(ownerPid)}</span>`
1653
1674
  : "";
1654
- processEl.innerHTML = agent
1655
- ? `${ownerTag}<span>${agent.cpu}% cpu</span><span>${agent.mem}% mem</span>`
1656
- : ownerTag;
1675
+ let procTag = "";
1676
+ if (matches.length) {
1677
+ const cpu = matches.reduce((sum, a) => sum + (a.cpu || 0), 0);
1678
+ const mem = matches.reduce((sum, a) => sum + (a.mem || 0), 0);
1679
+ const anyVerified = matches.some((a) => a.verified);
1680
+ const title = anyVerified
1681
+ ? "process cwd confirmed inside this task's worktree"
1682
+ : "matched by task id in the command line only — cwd could not be confirmed";
1683
+ procTag = `<span title="${title}">${matches.length > 1 ? `${matches.length} procs · ` : ""}${cpu.toFixed(1)}% cpu</span><span>${mem.toFixed(1)}% mem</span>${anyVerified ? "" : `<span class="worktree-state" style="opacity:0.7" title="${title}">unverified</span>`}`;
1684
+ } else if (card?.dataset.cardState !== "merged" && card?.dataset.cardState !== "removed") {
1685
+ procTag = `<span class="worktree-state not-created" title="task is in_progress but no matching process was found — it may have exited or crashed">no process detected</span>`;
1686
+ }
1687
+ processEl.innerHTML = ownerTag + procTag;
1657
1688
  }
1658
1689
  if (filesEl) {
1659
1690
  filesEl.innerHTML = data.files?.length
@@ -1681,6 +1712,38 @@
1681
1712
  $("#stats").innerHTML = rows.join("");
1682
1713
  }
1683
1714
 
1715
+ // Global navbar "active agents" pill. Independent of the per-page
1716
+ // worktree-card polling (mountWorktreeCards/pollWorktreeStatus) so the
1717
+ // count stays live on every route, not just the overview — and NOT
1718
+ // cleared by stopLiveTimers(), which runs on every navigation.
1719
+ let agentsPillTimer = null;
1720
+
1721
+ function renderAgentsPill() {
1722
+ const el = $("#agents-pill");
1723
+ if (!el) return;
1724
+ const total = agentProcesses.length;
1725
+ const verified = agentProcesses.filter((a) => a.verified).length;
1726
+ const unverified = total - verified;
1727
+ el.classList.toggle("live", total > 0);
1728
+ const title = total === 0
1729
+ ? "No active agent processes detected"
1730
+ : unverified > 0
1731
+ ? `${verified} verified in their task's worktree, ${unverified} matched by task id only (unconfirmed)`
1732
+ : `${verified} verified in their task's worktree`;
1733
+ el.title = title;
1734
+ el.innerHTML = `<span class="dot"></span><span class="count" title="${escapeHtml(title)}">${total}</span><span class="label">active ${total === 1 ? "agent" : "agents"}</span>`;
1735
+ }
1736
+
1737
+ async function pollAgentsGlobal() {
1738
+ try {
1739
+ const agents = await api("GET", "/api/agents");
1740
+ agentProcesses = Array.isArray(agents) ? agents : [];
1741
+ } catch {
1742
+ agentProcesses = [];
1743
+ }
1744
+ renderAgentsPill();
1745
+ }
1746
+
1684
1747
  // ================= Render =================
1685
1748
  const ROUTES = [
1686
1749
  { match: (s) => s.length === 0, page: pageOverview },
@@ -1801,6 +1864,8 @@
1801
1864
  render();
1802
1865
  connectSse();
1803
1866
  api("GET", "/api/context").then((ctx) => { appContext = ctx; }).catch(() => {});
1867
+ pollAgentsGlobal();
1868
+ agentsPillTimer = setInterval(pollAgentsGlobal, 5000);
1804
1869
  </script>
1805
1870
  </body>
1806
1871
  </html>
package/src/server.ts CHANGED
@@ -443,7 +443,9 @@ export function startServer(opts: ServerOptions): { url: string; stop: () => voi
443
443
 
444
444
  // GET /api/agents — running opencode processes with task matching
445
445
  if (path === "/api/agents" && method === "GET") {
446
- const agents = await listAgentProcesses();
446
+ const inProgress = listTasks(db, { status: "in_progress" });
447
+ const expected = inProgress.map((t) => ({ taskId: t.id, worktree: t.worktree }));
448
+ const agents = await listAgentProcesses(expected);
447
449
  return json(agents);
448
450
  }
449
451