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 +15 -0
- package/package.json +1 -1
- package/src/agents-status.ts +66 -13
- package/src/cli.ts +1 -1
- package/src/dashboard.html +82 -17
- package/src/server.ts +3 -1
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
package/src/agents-status.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
12
|
-
|
|
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
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
|
@@ -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
|
|
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
|
|