feinai 0.8.2 → 0.8.4

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,27 @@
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.4] - 2026-09-02
7
+
8
+ ### Fixed
9
+
10
+ - **Detección de agentes activos no reflejaba agentes reales.** El matching en 0.8.3 seguía dependiendo de encontrar el substring `TASK-...` en la línea de comando del proceso — pero sesiones reales (`claude`, `opencode` corriendo cd'd al worktree de la tarea) casi nunca tienen el task id en su `argv`, así que nunca matcheaban. Además `task.worktree` se guarda relativo al root del proyecto (`.worktrees/TASK-XXX`) y se comparaba tal cual contra rutas absolutas de `/proc/:pid/cwd`, que tampoco matcheaban nunca. `listAgentProcesses` ahora usa el cwd del proceso como señal primaria: escanea `/proc/*/cwd` de todos los procesos y los compara contra el worktree absoluto de cada tarea `in_progress` (resuelto en `server.ts` con `resolve(process.cwd(), task.worktree)`). El grep por `TASK-...` en el comando queda sólo como fallback de baja confianza (`verified: false`) para tareas sin worktree.
11
+
12
+ ## [0.8.3] - 2026-09-02
13
+
14
+ ### Added
15
+
16
+ - **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.
17
+
18
+ ### Fixed
19
+
20
+ - **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`.
21
+
22
+ ### Changed
23
+
24
+ - **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).
25
+ - 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.
26
+
6
27
  ## [0.8.2] - 2026-09-02
7
28
 
8
29
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feinai",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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,108 @@
1
+ import { readdir, 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
+ /** absolute path to the task's worktree, or null if it has none */
16
+ worktree: string | null;
17
+ }
18
+
19
+ interface PsRow {
20
+ pid: number;
21
+ cpu: number;
22
+ mem: number;
23
+ command: string;
7
24
  }
8
25
 
9
- export async function listAgentProcesses(): Promise<AgentProcess[]> {
26
+ async function psSnapshot(): Promise<PsRow[]> {
10
27
  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));
28
+ const result = await Bun.$`ps -eo pid,pcpu,pmem,command`.text();
29
+ return result
30
+ .trim()
31
+ .split("\n")
32
+ .slice(1)
33
+ .map((line) => {
34
+ const parts = line.trim().split(/\s+/);
35
+ return {
36
+ pid: parseInt(parts[0]!, 10),
37
+ cpu: parseFloat(parts[1]!),
38
+ mem: parseFloat(parts[2]!),
39
+ command: parts.slice(3).join(" "),
40
+ };
41
+ })
42
+ .filter((p) => Number.isFinite(p.pid));
23
43
  } catch {
24
44
  return [];
25
45
  }
26
46
  }
47
+
48
+ /**
49
+ * Snapshot of processes working on active tasks.
50
+ *
51
+ * Real agent sessions here (an interactive `claude` or `opencode` process cd'd
52
+ * into a task's git worktree) rarely put the task ID anywhere in their
53
+ * command line — so grepping ps output for `TASK-...` misses them entirely.
54
+ * The reliable signal is the process's current working directory: for each
55
+ * in_progress task with a worktree, we check every process's
56
+ * `/proc/:pid/cwd` against that worktree path. A cwd match is `verified: true`.
57
+ *
58
+ * As a fallback (e.g. a task has no worktree yet, or /proc isn't readable),
59
+ * we also look for a literal `TASK-...` substring in the command line —
60
+ * kept as `verified: false` since it's coincidental at best.
61
+ */
62
+ export async function listAgentProcesses(
63
+ expected: ExpectedAgent[] = [],
64
+ ): Promise<AgentProcess[]> {
65
+ const rows = await psSnapshot();
66
+ if (!rows.length) return [];
67
+
68
+ const pids = await readdir("/proc").catch(() => [] as string[]);
69
+ const numericPids = new Set(pids.filter((p) => /^\d+$/.test(p)).map(Number));
70
+
71
+ const cwdByPid = new Map<number, string>();
72
+ await Promise.all(
73
+ rows
74
+ .filter((r) => numericPids.has(r.pid))
75
+ .map(async (r) => {
76
+ try {
77
+ cwdByPid.set(r.pid, await readlink(`/proc/${r.pid}/cwd`));
78
+ } catch {
79
+ // process exited or unreadable — no cwd signal for it
80
+ }
81
+ }),
82
+ );
83
+
84
+ const results: AgentProcess[] = [];
85
+ const matchedPids = new Set<number>();
86
+
87
+ for (const { taskId, worktree } of expected) {
88
+ if (!worktree) continue;
89
+ for (const row of rows) {
90
+ if (matchedPids.has(row.pid)) continue;
91
+ const cwd = cwdByPid.get(row.pid);
92
+ if (!cwd) continue;
93
+ if (cwd === worktree || cwd.startsWith(worktree + "/")) {
94
+ matchedPids.add(row.pid);
95
+ results.push({ ...row, taskId, verified: true });
96
+ }
97
+ }
98
+ }
99
+
100
+ for (const row of rows) {
101
+ if (matchedPids.has(row.pid)) continue;
102
+ const taskMatch = row.command.match(/TASK-[A-Z0-9-]+/);
103
+ if (!taskMatch) continue;
104
+ results.push({ ...row, taskId: taskMatch[0], verified: false });
105
+ }
106
+
107
+ return results;
108
+ }
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.4";
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
@@ -441,9 +441,17 @@ export function startServer(opts: ServerOptions): { url: string; stop: () => voi
441
441
  return json(status);
442
442
  }
443
443
 
444
- // GET /api/agents — running opencode processes with task matching
444
+ // GET /api/agents — running agent 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
+ // task.worktree is stored relative to the project root the server
448
+ // was launched from — resolve it so it can be compared against the
449
+ // absolute paths /proc/:pid/cwd returns.
450
+ const expected = inProgress.map((t) => ({
451
+ taskId: t.id,
452
+ worktree: t.worktree ? resolve(process.cwd(), t.worktree) : null,
453
+ }));
454
+ const agents = await listAgentProcesses(expected);
447
455
  return json(agents);
448
456
  }
449
457