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 +21 -0
- package/package.json +1 -1
- package/src/agents-status.ts +95 -13
- package/src/cli.ts +1 -1
- package/src/dashboard.html +82 -17
- package/src/server.ts +10 -2
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
package/src/agents-status.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
+
async function psSnapshot(): Promise<PsRow[]> {
|
|
10
27
|
try {
|
|
11
|
-
const result = await Bun.$`ps -eo pid,pcpu,pmem,command
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
package/src/dashboard.html
CHANGED
|
@@ -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
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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
|
|
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
|
|
691
|
+
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
673
692
|
}
|
|
674
693
|
|
|
675
694
|
function elapsedSince(iso) {
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
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
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
|
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
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
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
|
|
444
|
+
// GET /api/agents — running agent processes with task matching
|
|
445
445
|
if (path === "/api/agents" && method === "GET") {
|
|
446
|
-
const
|
|
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
|
|