consensus-cli 0.1.2 → 0.1.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
@@ -5,6 +5,16 @@ This project follows Semantic Versioning.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.1.4 - 2026-01-24
9
+ - Fix OpenCode event tracking build error (pid activity typing).
10
+
11
+ ## 0.1.3 - 2026-01-24
12
+ - Add OpenCode integration (API sessions, event stream, storage fallback).
13
+ - Autostart OpenCode server with opt-out and CLI flags.
14
+ - Split servers into a dedicated lane with distinct palette.
15
+ - Improve layout keys to prevent tile overlap.
16
+ - Add OpenCode unit/integration tests and configuration docs.
17
+
8
18
  ## 0.1.2 - 2026-01-24
9
19
  - Lower CPU threshold for active detection.
10
20
  - Increase activity window defaults for long-running turns.
package/README.md CHANGED
@@ -62,6 +62,10 @@ consensus dev server running on http://127.0.0.1:8787
62
62
  - `CONSENSUS_PORT`: server port (default `8787`).
63
63
  - `CONSENSUS_POLL_MS`: polling interval in ms (default `1000`).
64
64
  - `CONSENSUS_CODEX_HOME`: override Codex home (default `~/.codex`).
65
+ - `CONSENSUS_OPENCODE_HOST`: OpenCode server host (default `127.0.0.1`).
66
+ - `CONSENSUS_OPENCODE_PORT`: OpenCode server port (default `4096`).
67
+ - `CONSENSUS_OPENCODE_AUTOSTART`: set to `0` to disable OpenCode autostart.
68
+ - `CONSENSUS_OPENCODE_EVENTS`: set to `0` to disable OpenCode event stream.
65
69
  - `CONSENSUS_PROCESS_MATCH`: regex to match codex processes.
66
70
  - `CONSENSUS_REDACT_PII`: set to `0` to disable redaction (default enabled).
67
71
  - `CONSENSUS_EVENT_ACTIVE_MS`: active window after last event in ms (default `300000`).
package/dist/cli.js CHANGED
@@ -27,6 +27,9 @@ function printHelp() {
27
27
  process.stdout.write(` --port <port> Port (default 8787)\n`);
28
28
  process.stdout.write(` --poll <ms> Poll interval in ms\n`);
29
29
  process.stdout.write(` --codex-home <path> Override CODEX_HOME\n`);
30
+ process.stdout.write(` --opencode-host <h> OpenCode host (default 127.0.0.1)\n`);
31
+ process.stdout.write(` --opencode-port <p> OpenCode port (default 4096)\n`);
32
+ process.stdout.write(` --no-opencode-autostart Disable OpenCode server autostart\n`);
30
33
  process.stdout.write(` --process-match <re> Regex for process matching\n`);
31
34
  process.stdout.write(` --no-redact Disable PII redaction\n`);
32
35
  process.stdout.write(` -h, --help Show help\n`);
@@ -40,6 +43,9 @@ const host = readArg("--host");
40
43
  const port = readArg("--port");
41
44
  const poll = readArg("--poll");
42
45
  const codexHome = readArg("--codex-home");
46
+ const opencodeHost = readArg("--opencode-host");
47
+ const opencodePort = readArg("--opencode-port");
48
+ const noOpenCodeAutostart = hasFlag("--no-opencode-autostart");
43
49
  const match = readArg("--process-match");
44
50
  const noRedact = hasFlag("--no-redact");
45
51
  if (host)
@@ -50,6 +56,12 @@ if (poll)
50
56
  env.CONSENSUS_POLL_MS = poll;
51
57
  if (codexHome)
52
58
  env.CONSENSUS_CODEX_HOME = codexHome;
59
+ if (opencodeHost)
60
+ env.CONSENSUS_OPENCODE_HOST = opencodeHost;
61
+ if (opencodePort)
62
+ env.CONSENSUS_OPENCODE_PORT = opencodePort;
63
+ if (noOpenCodeAutostart)
64
+ env.CONSENSUS_OPENCODE_AUTOSTART = "0";
53
65
  if (match)
54
66
  env.CONSENSUS_PROCESS_MATCH = match;
55
67
  if (noRedact)
@@ -0,0 +1,84 @@
1
+ function shouldWarn(options) {
2
+ return options?.silent ? false : true;
3
+ }
4
+ export async function getOpenCodeSessions(host = "localhost", port = 4096, options) {
5
+ const controller = new AbortController();
6
+ const timeoutMs = options?.timeoutMs ?? 5000;
7
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
8
+ const warn = shouldWarn(options);
9
+ try {
10
+ const response = await fetch(`http://${host}:${port}/session`, {
11
+ headers: {
12
+ Accept: "application/json",
13
+ "User-Agent": "consensus-scanner",
14
+ },
15
+ signal: controller.signal,
16
+ });
17
+ clearTimeout(timeoutId);
18
+ if (!response.ok) {
19
+ if (warn) {
20
+ console.warn(`OpenCode API error: ${response.status} ${response.statusText}`);
21
+ }
22
+ return { ok: false, sessions: [], status: response.status, reachable: true };
23
+ }
24
+ const contentType = response.headers.get("content-type") || "";
25
+ if (!contentType.includes("json")) {
26
+ if (warn) {
27
+ console.warn(`OpenCode API non-JSON response (${contentType || "unknown"})`);
28
+ }
29
+ return { ok: false, sessions: [], status: response.status, reachable: true, error: "non_json" };
30
+ }
31
+ const payload = await response.json();
32
+ if (Array.isArray(payload))
33
+ return { ok: true, sessions: payload, reachable: true };
34
+ if (payload && typeof payload === "object" && Array.isArray(payload.sessions)) {
35
+ return { ok: true, sessions: payload.sessions, reachable: true };
36
+ }
37
+ if (payload && typeof payload === "object" && Array.isArray(payload.data)) {
38
+ return { ok: true, sessions: payload.data, reachable: true };
39
+ }
40
+ return { ok: true, sessions: [], reachable: true };
41
+ }
42
+ catch (error) {
43
+ clearTimeout(timeoutId);
44
+ if (warn) {
45
+ console.warn("Failed to fetch OpenCode sessions:", error);
46
+ }
47
+ const errorCode = typeof error?.cause?.code === "string"
48
+ ? error.cause.code
49
+ : typeof error?.code === "string"
50
+ ? error.code
51
+ : undefined;
52
+ return { ok: false, sessions: [], error: errorCode, reachable: false };
53
+ }
54
+ }
55
+ export async function getOpenCodeSession(sessionId, host = "localhost", port = 4096, options) {
56
+ const controller = new AbortController();
57
+ const timeoutMs = options?.timeoutMs ?? 5000;
58
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
59
+ const warn = shouldWarn(options);
60
+ try {
61
+ const response = await fetch(`http://${host}:${port}/session/${sessionId}`, {
62
+ headers: {
63
+ Accept: "application/json",
64
+ "User-Agent": "consensus-scanner",
65
+ },
66
+ signal: controller.signal,
67
+ });
68
+ clearTimeout(timeoutId);
69
+ if (!response.ok) {
70
+ if (warn) {
71
+ console.warn(`OpenCode API error for session ${sessionId}: ${response.status} ${response.statusText}`);
72
+ }
73
+ return null;
74
+ }
75
+ return await response.json();
76
+ }
77
+ catch (error) {
78
+ clearTimeout(timeoutId);
79
+ if (warn) {
80
+ console.warn(`Failed to fetch OpenCode session ${sessionId}:`, error);
81
+ }
82
+ return null;
83
+ }
84
+ }
@@ -0,0 +1,359 @@
1
+ import { redactText } from "./redact.js";
2
+ const MAX_EVENTS = 50;
3
+ const STALE_TTL_MS = 30 * 60 * 1000;
4
+ const RECONNECT_MIN_MS = 10000;
5
+ const sessionActivity = new Map();
6
+ const pidActivity = new Map();
7
+ let connecting = false;
8
+ let connected = false;
9
+ let lastConnectAt = 0;
10
+ let lastFailureAt = 0;
11
+ function nowMs() {
12
+ return Date.now();
13
+ }
14
+ function parseTimestamp(value) {
15
+ if (typeof value === "number" && Number.isFinite(value)) {
16
+ return value < 100000000000 ? value * 1000 : value;
17
+ }
18
+ if (typeof value === "string") {
19
+ const parsed = Date.parse(value);
20
+ if (!Number.isNaN(parsed))
21
+ return parsed;
22
+ }
23
+ return nowMs();
24
+ }
25
+ function extractText(value) {
26
+ if (typeof value === "string")
27
+ return value;
28
+ if (Array.isArray(value))
29
+ return value.map(extractText).filter(Boolean).join(" ");
30
+ if (value && typeof value === "object") {
31
+ if (typeof value.text === "string")
32
+ return value.text;
33
+ if (typeof value.content === "string")
34
+ return value.content;
35
+ if (typeof value.message === "string")
36
+ return value.message;
37
+ if (value.message && typeof value.message.content === "string") {
38
+ return value.message.content;
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+ function getSessionId(raw) {
44
+ return (raw?.sessionId ||
45
+ raw?.session_id ||
46
+ raw?.session?.id ||
47
+ raw?.session?.sessionId ||
48
+ raw?.properties?.sessionId ||
49
+ raw?.properties?.session_id);
50
+ }
51
+ function getPid(raw) {
52
+ const pid = raw?.pid ||
53
+ raw?.process?.pid ||
54
+ raw?.properties?.pid ||
55
+ raw?.properties?.processId;
56
+ if (typeof pid === "number" && Number.isFinite(pid))
57
+ return pid;
58
+ if (typeof pid === "string") {
59
+ const parsed = Number(pid);
60
+ if (!Number.isNaN(parsed))
61
+ return parsed;
62
+ }
63
+ return undefined;
64
+ }
65
+ function summarizeEvent(raw) {
66
+ const typeRaw = raw?.type ||
67
+ raw?.event ||
68
+ raw?.name ||
69
+ raw?.kind ||
70
+ raw?.properties?.type ||
71
+ "event";
72
+ const type = typeof typeRaw === "string" ? typeRaw : "event";
73
+ const lowerType = type.toLowerCase();
74
+ const status = raw?.status || raw?.state || raw?.properties?.status;
75
+ const statusStr = typeof status === "string" ? status.toLowerCase() : "";
76
+ const isError = !!raw?.error || lowerType.includes("error") || statusStr.includes("error");
77
+ let inFlight;
78
+ if (lowerType.includes("started") ||
79
+ statusStr.includes("started") ||
80
+ statusStr.includes("running") ||
81
+ statusStr.includes("processing") ||
82
+ statusStr.includes("in_progress")) {
83
+ inFlight = true;
84
+ }
85
+ else if (lowerType.includes("completed") ||
86
+ lowerType.includes("finished") ||
87
+ lowerType.includes("done") ||
88
+ lowerType.includes("ended") ||
89
+ statusStr.includes("completed") ||
90
+ statusStr.includes("finished") ||
91
+ statusStr.includes("done") ||
92
+ statusStr.includes("ended") ||
93
+ statusStr.includes("idle") ||
94
+ statusStr.includes("stopped") ||
95
+ statusStr.includes("paused") ||
96
+ isError) {
97
+ inFlight = false;
98
+ }
99
+ if (lowerType.includes("compaction")) {
100
+ const phase = statusStr || raw?.phase || raw?.properties?.phase;
101
+ const summary = phase ? `compaction: ${phase}` : "compaction";
102
+ return { summary, kind: "other", isError, type, inFlight };
103
+ }
104
+ const cmd = raw?.command ||
105
+ raw?.cmd ||
106
+ raw?.input?.command ||
107
+ raw?.input?.cmd ||
108
+ raw?.properties?.command ||
109
+ raw?.properties?.cmd ||
110
+ (Array.isArray(raw?.args) ? raw.args.join(" ") : undefined);
111
+ if (typeof cmd === "string" && cmd.trim()) {
112
+ const summary = redactText(`cmd: ${cmd.trim()}`) || `cmd: ${cmd.trim()}`;
113
+ return { summary, kind: "command", isError, type, inFlight };
114
+ }
115
+ const pathHint = raw?.path ||
116
+ raw?.file ||
117
+ raw?.filename ||
118
+ raw?.target ||
119
+ raw?.properties?.path ||
120
+ raw?.properties?.file;
121
+ if (typeof pathHint === "string" && pathHint.trim() && lowerType.includes("file")) {
122
+ const summary = redactText(`edit: ${pathHint.trim()}`) || `edit: ${pathHint.trim()}`;
123
+ return { summary, kind: "edit", isError, type };
124
+ }
125
+ const tool = raw?.tool ||
126
+ raw?.tool_name ||
127
+ raw?.toolName ||
128
+ raw?.properties?.tool ||
129
+ raw?.properties?.tool_name;
130
+ if (typeof tool === "string" && tool.trim() && lowerType.includes("tool")) {
131
+ const summary = redactText(`tool: ${tool.trim()}`) || `tool: ${tool.trim()}`;
132
+ return { summary, kind: "tool", isError, type, inFlight };
133
+ }
134
+ const promptText = extractText(raw?.prompt) ||
135
+ extractText(raw?.input) ||
136
+ extractText(raw?.instruction) ||
137
+ extractText(raw?.properties?.prompt);
138
+ if (promptText && lowerType.includes("prompt")) {
139
+ const trimmed = promptText.replace(/\s+/g, " ").trim();
140
+ const snippet = trimmed.slice(0, 120);
141
+ const summary = redactText(`prompt: ${snippet}`) || `prompt: ${snippet}`;
142
+ return { summary, kind: "prompt", isError, type };
143
+ }
144
+ const messageText = extractText(raw?.message) ||
145
+ extractText(raw?.content) ||
146
+ extractText(raw?.text) ||
147
+ extractText(raw?.properties?.message);
148
+ if (messageText) {
149
+ const trimmed = messageText.replace(/\s+/g, " ").trim();
150
+ const snippet = trimmed.slice(0, 80);
151
+ const summary = redactText(snippet) || snippet;
152
+ return { summary, kind: "message", isError, type };
153
+ }
154
+ if (type && type !== "event") {
155
+ const summary = redactText(`event: ${type}`) || `event: ${type}`;
156
+ return { summary, kind: "other", isError, type };
157
+ }
158
+ return { kind: "other", isError, type };
159
+ }
160
+ function ensureActivity(key, map, now) {
161
+ const existing = map.get(key);
162
+ if (existing) {
163
+ existing.lastSeenAt = now;
164
+ return existing;
165
+ }
166
+ const fresh = {
167
+ events: [],
168
+ summary: {},
169
+ lastSeenAt: now,
170
+ };
171
+ map.set(key, fresh);
172
+ return fresh;
173
+ }
174
+ function recordEvent(state, entry, kind) {
175
+ state.events.push(entry);
176
+ if (state.events.length > MAX_EVENTS) {
177
+ state.events = state.events.slice(-MAX_EVENTS);
178
+ }
179
+ state.lastEventAt = Math.max(state.lastEventAt || 0, entry.ts);
180
+ if (kind === "command")
181
+ state.lastCommand = entry;
182
+ if (kind === "edit")
183
+ state.lastEdit = entry;
184
+ if (kind === "message")
185
+ state.lastMessage = entry;
186
+ if (kind === "tool")
187
+ state.lastTool = entry;
188
+ if (kind === "prompt")
189
+ state.lastPrompt = entry;
190
+ if (entry.isError)
191
+ state.lastError = entry;
192
+ state.summary = {
193
+ current: state.events[state.events.length - 1]?.summary,
194
+ lastCommand: state.lastCommand?.summary,
195
+ lastEdit: state.lastEdit?.summary,
196
+ lastMessage: state.lastMessage?.summary,
197
+ lastTool: state.lastTool?.summary,
198
+ lastPrompt: state.lastPrompt?.summary,
199
+ };
200
+ }
201
+ function handleRawEvent(raw) {
202
+ const ts = parseTimestamp(raw?.ts ||
203
+ raw?.timestamp ||
204
+ raw?.time ||
205
+ raw?.created_at ||
206
+ raw?.createdAt ||
207
+ raw?.properties?.time ||
208
+ raw?.properties?.timestamp);
209
+ const sessionId = getSessionId(raw);
210
+ const pid = getPid(raw);
211
+ const { summary, kind, isError, type, inFlight } = summarizeEvent(raw);
212
+ if (!summary)
213
+ return;
214
+ const entry = {
215
+ ts,
216
+ type: typeof type === "string" ? type : "event",
217
+ summary,
218
+ isError,
219
+ };
220
+ const now = nowMs();
221
+ if (sessionId) {
222
+ const state = ensureActivity(sessionId, sessionActivity, now);
223
+ recordEvent(state, entry, kind);
224
+ if (typeof inFlight === "boolean")
225
+ state.inFlight = inFlight;
226
+ }
227
+ if (typeof pid === "number") {
228
+ const state = ensureActivity(pid, pidActivity, now);
229
+ recordEvent(state, entry, kind);
230
+ if (typeof inFlight === "boolean")
231
+ state.inFlight = inFlight;
232
+ }
233
+ }
234
+ function pruneStale() {
235
+ const cutoff = nowMs() - STALE_TTL_MS;
236
+ for (const [key, state] of sessionActivity.entries()) {
237
+ if (state.lastSeenAt < cutoff)
238
+ sessionActivity.delete(key);
239
+ }
240
+ for (const [key, state] of pidActivity.entries()) {
241
+ if (state.lastSeenAt < cutoff)
242
+ pidActivity.delete(key);
243
+ }
244
+ }
245
+ async function connectStream(host, port) {
246
+ connecting = true;
247
+ lastConnectAt = nowMs();
248
+ try {
249
+ const response = await fetch(`http://${host}:${port}/global/event`, {
250
+ headers: {
251
+ Accept: "text/event-stream",
252
+ },
253
+ });
254
+ if (!response.ok || !response.body) {
255
+ connected = false;
256
+ connecting = false;
257
+ lastFailureAt = nowMs();
258
+ return;
259
+ }
260
+ connected = true;
261
+ connecting = false;
262
+ const reader = response.body.getReader();
263
+ let buffer = "";
264
+ let currentEvent;
265
+ let dataLines = [];
266
+ while (true) {
267
+ const { value, done } = await reader.read();
268
+ if (done)
269
+ break;
270
+ buffer += Buffer.from(value).toString("utf8");
271
+ let idx;
272
+ while ((idx = buffer.indexOf("\n")) !== -1) {
273
+ const line = buffer.slice(0, idx).trimEnd();
274
+ buffer = buffer.slice(idx + 1);
275
+ if (!line) {
276
+ if (dataLines.length) {
277
+ const payload = dataLines.join("\n");
278
+ try {
279
+ const parsed = JSON.parse(payload);
280
+ const raw = parsed?.payload ?? parsed;
281
+ if (currentEvent && typeof raw === "object" && !raw.type) {
282
+ raw.type = currentEvent;
283
+ }
284
+ if (parsed?.type && typeof raw === "object" && !raw.type) {
285
+ raw.type = parsed.type;
286
+ }
287
+ handleRawEvent(raw);
288
+ }
289
+ catch {
290
+ // ignore malformed payloads
291
+ }
292
+ }
293
+ currentEvent = undefined;
294
+ dataLines = [];
295
+ continue;
296
+ }
297
+ if (line.startsWith("event:")) {
298
+ currentEvent = line.slice(6).trim();
299
+ continue;
300
+ }
301
+ if (line.startsWith("data:")) {
302
+ dataLines.push(line.slice(5).trim());
303
+ }
304
+ }
305
+ }
306
+ }
307
+ catch {
308
+ lastFailureAt = nowMs();
309
+ }
310
+ finally {
311
+ connected = false;
312
+ connecting = false;
313
+ }
314
+ }
315
+ export function ensureOpenCodeEventStream(host, port) {
316
+ if (process.env.CONSENSUS_OPENCODE_EVENTS === "0")
317
+ return;
318
+ const now = nowMs();
319
+ if (connecting || connected)
320
+ return;
321
+ if (now - lastConnectAt < RECONNECT_MIN_MS)
322
+ return;
323
+ if (now - lastFailureAt < RECONNECT_MIN_MS)
324
+ return;
325
+ pruneStale();
326
+ void connectStream(host, port);
327
+ }
328
+ export function getOpenCodeActivityBySession(sessionId) {
329
+ if (!sessionId)
330
+ return null;
331
+ const state = sessionActivity.get(sessionId);
332
+ if (!state)
333
+ return null;
334
+ const events = state.events.slice(-20);
335
+ const hasError = !!state.lastError || events.some((ev) => ev.isError);
336
+ return {
337
+ events,
338
+ summary: state.summary,
339
+ lastEventAt: state.lastEventAt,
340
+ hasError,
341
+ inFlight: state.inFlight,
342
+ };
343
+ }
344
+ export function getOpenCodeActivityByPid(pid) {
345
+ if (typeof pid !== "number")
346
+ return null;
347
+ const state = pidActivity.get(pid);
348
+ if (!state)
349
+ return null;
350
+ const events = state.events.slice(-20);
351
+ const hasError = !!state.lastError || events.some((ev) => ev.isError);
352
+ return {
353
+ events,
354
+ summary: state.summary,
355
+ lastEventAt: state.lastEventAt,
356
+ hasError,
357
+ inFlight: state.inFlight,
358
+ };
359
+ }
@@ -0,0 +1,91 @@
1
+ import { execFile, spawn } from "child_process";
2
+ import { promisify } from "util";
3
+ import { getOpenCodeSessions } from "./opencodeApi.js";
4
+ const execFileAsync = promisify(execFile);
5
+ const AUTOSTART_ENABLED = process.env.CONSENSUS_OPENCODE_AUTOSTART !== "0";
6
+ const CHECK_INTERVAL_MS = 30000;
7
+ const INSTALL_CHECK_INTERVAL_MS = 5 * 60000;
8
+ const START_BACKOFF_MS = 60000;
9
+ let lastAttemptAt = 0;
10
+ let lastInstallCheck = 0;
11
+ let lastStartAt = 0;
12
+ let opencodeInstalled = null;
13
+ let startedPid = null;
14
+ let startInFlight = false;
15
+ async function isOpenCodeInstalled() {
16
+ const now = Date.now();
17
+ if (opencodeInstalled !== null && now - lastInstallCheck < INSTALL_CHECK_INTERVAL_MS) {
18
+ return opencodeInstalled;
19
+ }
20
+ lastInstallCheck = now;
21
+ try {
22
+ await execFileAsync("opencode", ["--version"]);
23
+ opencodeInstalled = true;
24
+ }
25
+ catch (error) {
26
+ if (error?.code === "ENOENT") {
27
+ opencodeInstalled = false;
28
+ }
29
+ else {
30
+ opencodeInstalled = true;
31
+ }
32
+ }
33
+ return opencodeInstalled;
34
+ }
35
+ function spawnOpenCodeServer(host, port) {
36
+ if (startInFlight)
37
+ return;
38
+ startInFlight = true;
39
+ const child = spawn("opencode", ["serve", "--hostname", host, "--port", String(port)], {
40
+ stdio: "ignore",
41
+ detached: true,
42
+ });
43
+ child.unref();
44
+ startedPid = child.pid ?? null;
45
+ child.on("error", () => {
46
+ startInFlight = false;
47
+ });
48
+ child.on("spawn", () => {
49
+ startInFlight = false;
50
+ });
51
+ }
52
+ export async function ensureOpenCodeServer(host, port, existingResult) {
53
+ if (!AUTOSTART_ENABLED)
54
+ return;
55
+ const now = Date.now();
56
+ if (now - lastAttemptAt < CHECK_INTERVAL_MS)
57
+ return;
58
+ lastAttemptAt = now;
59
+ const installed = await isOpenCodeInstalled();
60
+ if (!installed)
61
+ return;
62
+ const result = existingResult ?? (await getOpenCodeSessions(host, port, { silent: true }));
63
+ if (result.ok)
64
+ return;
65
+ if (result.reachable)
66
+ return;
67
+ if (now - lastStartAt < START_BACKOFF_MS)
68
+ return;
69
+ lastStartAt = now;
70
+ spawnOpenCodeServer(host, port);
71
+ }
72
+ function stopOpenCodeServer() {
73
+ if (!startedPid)
74
+ return;
75
+ try {
76
+ process.kill(startedPid);
77
+ }
78
+ catch {
79
+ // ignore failures
80
+ }
81
+ startedPid = null;
82
+ }
83
+ process.on("exit", stopOpenCodeServer);
84
+ process.on("SIGINT", () => {
85
+ stopOpenCodeServer();
86
+ process.exit(0);
87
+ });
88
+ process.on("SIGTERM", () => {
89
+ stopOpenCodeServer();
90
+ process.exit(0);
91
+ });
@@ -0,0 +1,127 @@
1
+ import fsp from "fs/promises";
2
+ import os from "os";
3
+ import path from "path";
4
+ const PROJECT_SCAN_INTERVAL_MS = 60000;
5
+ const SESSION_SCAN_INTERVAL_MS = 5000;
6
+ let projectCache = [];
7
+ let projectCacheAt = 0;
8
+ const sessionCache = new Map();
9
+ export function resolveOpenCodeHome(env = process.env) {
10
+ const override = env.CONSENSUS_OPENCODE_HOME;
11
+ if (override)
12
+ return path.resolve(override);
13
+ return path.join(os.homedir(), ".local", "share", "opencode");
14
+ }
15
+ async function listProjectEntries(home) {
16
+ const now = Date.now();
17
+ if (now - projectCacheAt < PROJECT_SCAN_INTERVAL_MS)
18
+ return projectCache;
19
+ projectCacheAt = now;
20
+ const projectDir = path.join(home, "storage", "project");
21
+ let entries;
22
+ try {
23
+ entries = await fsp.readdir(projectDir, { withFileTypes: true });
24
+ }
25
+ catch {
26
+ projectCache = [];
27
+ return projectCache;
28
+ }
29
+ const results = [];
30
+ for (const entry of entries) {
31
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
32
+ continue;
33
+ const fullPath = path.join(projectDir, entry.name);
34
+ try {
35
+ const raw = await fsp.readFile(fullPath, "utf8");
36
+ const data = JSON.parse(raw);
37
+ results.push({
38
+ id: data.id || entry.name.replace(/\.json$/, ""),
39
+ worktree: data.worktree || data.directory,
40
+ time: data.time,
41
+ });
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ }
47
+ projectCache = results;
48
+ return results;
49
+ }
50
+ function pickProjectId(projects, cwd) {
51
+ const normalized = cwd.replace(/\\/g, "/");
52
+ let best;
53
+ for (const project of projects) {
54
+ if (!project.worktree)
55
+ continue;
56
+ const projectPath = project.worktree.replace(/\\/g, "/");
57
+ if (normalized === projectPath || normalized.startsWith(`${projectPath}/`)) {
58
+ if (!best || (projectPath.length > (best.worktree?.length || 0))) {
59
+ best = project;
60
+ }
61
+ }
62
+ }
63
+ return best?.id;
64
+ }
65
+ async function readLatestSessionForProject(home, projectId) {
66
+ const now = Date.now();
67
+ const cached = sessionCache.get(projectId);
68
+ if (cached && now - cached.scannedAt < SESSION_SCAN_INTERVAL_MS) {
69
+ return cached.session;
70
+ }
71
+ const sessionDir = path.join(home, "storage", "session", projectId);
72
+ let entries;
73
+ try {
74
+ entries = await fsp.readdir(sessionDir, { withFileTypes: true });
75
+ }
76
+ catch {
77
+ sessionCache.set(projectId, { session: undefined, scannedAt: now });
78
+ return undefined;
79
+ }
80
+ let latestPath = null;
81
+ let latestMtime = 0;
82
+ for (const entry of entries) {
83
+ if (!entry.isFile() || !entry.name.startsWith("ses_") || !entry.name.endsWith(".json"))
84
+ continue;
85
+ const fullPath = path.join(sessionDir, entry.name);
86
+ try {
87
+ const stat = await fsp.stat(fullPath);
88
+ if (stat.mtimeMs > latestMtime) {
89
+ latestMtime = stat.mtimeMs;
90
+ latestPath = fullPath;
91
+ }
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ }
97
+ if (!latestPath) {
98
+ sessionCache.set(projectId, { session: undefined, scannedAt: now });
99
+ return undefined;
100
+ }
101
+ try {
102
+ const raw = await fsp.readFile(latestPath, "utf8");
103
+ const data = JSON.parse(raw);
104
+ const session = {
105
+ id: data.id,
106
+ title: data.title,
107
+ directory: data.directory,
108
+ time: data.time,
109
+ };
110
+ sessionCache.set(projectId, { session, scannedAt: now });
111
+ return session;
112
+ }
113
+ catch {
114
+ sessionCache.set(projectId, { session: undefined, scannedAt: now });
115
+ return undefined;
116
+ }
117
+ }
118
+ export async function getOpenCodeSessionForDirectory(cwd, env = process.env) {
119
+ if (!cwd)
120
+ return undefined;
121
+ const home = resolveOpenCodeHome(env);
122
+ const projects = await listProjectEntries(home);
123
+ const projectId = pickProjectId(projects, cwd);
124
+ if (!projectId)
125
+ return undefined;
126
+ return await readLatestSessionForProject(home, projectId);
127
+ }
package/dist/scan.js CHANGED
@@ -6,6 +6,10 @@ import { execFile } from "child_process";
6
6
  import { promisify } from "util";
7
7
  import { deriveStateWithHold } from "./activity.js";
8
8
  import { listRecentSessions, findSessionById, pickSessionForProcess, resolveCodexHome, summarizeTail, updateTail, } from "./codexLogs.js";
9
+ import { getOpenCodeSessions } from "./opencodeApi.js";
10
+ import { ensureOpenCodeServer } from "./opencodeServer.js";
11
+ import { ensureOpenCodeEventStream, getOpenCodeActivityByPid, getOpenCodeActivityBySession, } from "./opencodeEvents.js";
12
+ import { getOpenCodeSessionForDirectory } from "./opencodeStorage.js";
9
13
  import { redactText } from "./redact.js";
10
14
  const execFileAsync = promisify(execFile);
11
15
  const repoCache = new Map();
@@ -27,13 +31,34 @@ function isCodexProcess(cmd, name, matchRe) {
27
31
  return true;
28
32
  return false;
29
33
  }
34
+ function isOpenCodeProcess(cmd, name) {
35
+ if (!cmd && !name)
36
+ return false;
37
+ if (name === "opencode")
38
+ return true;
39
+ if (!cmd)
40
+ return false;
41
+ const firstToken = cmd.trim().split(/\s+/)[0];
42
+ const base = path.basename(firstToken);
43
+ if (base === "opencode")
44
+ return true;
45
+ return false;
46
+ }
30
47
  function inferKind(cmd) {
31
48
  if (cmd.includes(" app-server"))
32
49
  return "app-server";
33
50
  if (cmd.includes(" exec"))
34
51
  return "exec";
35
- if (cmd.includes(" codex") || cmd.startsWith("codex"))
52
+ if (cmd.includes(" codex") || cmd.startsWith("codex") || cmd.includes("/codex"))
36
53
  return "tui";
54
+ if (cmd.includes(" opencode") || cmd.startsWith("opencode") || cmd.includes("/opencode")) {
55
+ if (cmd.includes(" serve") || cmd.includes("--serve") || cmd.includes(" web")) {
56
+ return "opencode-server";
57
+ }
58
+ if (cmd.includes(" run"))
59
+ return "opencode-cli";
60
+ return "opencode-tui";
61
+ }
37
62
  return "unknown";
38
63
  }
39
64
  function shortenCmd(cmd, max = 120) {
@@ -44,6 +69,22 @@ function shortenCmd(cmd, max = 120) {
44
69
  }
45
70
  function parseDoingFromCmd(cmd) {
46
71
  const parts = cmd.split(/\s+/g);
72
+ const openIndex = parts.findIndex((part) => part === "opencode" || part.endsWith("/opencode"));
73
+ if (openIndex !== -1) {
74
+ const mode = parts[openIndex + 1];
75
+ if (mode === "serve" || mode === "web")
76
+ return `opencode ${mode}`;
77
+ if (mode === "run") {
78
+ for (let i = openIndex + 2; i < parts.length; i += 1) {
79
+ const part = parts[i];
80
+ if (!part || part.startsWith("-"))
81
+ continue;
82
+ return `opencode run: ${part}`;
83
+ }
84
+ return "opencode run";
85
+ }
86
+ return "opencode";
87
+ }
47
88
  const execIndex = parts.indexOf("exec");
48
89
  if (execIndex !== -1) {
49
90
  for (let i = execIndex + 1; i < parts.length; i += 1) {
@@ -89,12 +130,43 @@ function extractSessionId(cmd) {
89
130
  }
90
131
  return undefined;
91
132
  }
133
+ function extractOpenCodeSessionId(cmd) {
134
+ const parts = cmd.split(/\s+/g);
135
+ const sessionFlag = parts.findIndex((part) => part === "--session" || part === "--session-id" || part === "-s");
136
+ if (sessionFlag !== -1) {
137
+ const token = parts[sessionFlag + 1];
138
+ if (token)
139
+ return token;
140
+ }
141
+ return undefined;
142
+ }
92
143
  function normalizeTitle(value) {
93
144
  if (!value)
94
145
  return undefined;
95
146
  return value.replace(/^prompt:\s*/i, "").trim();
96
147
  }
97
- function deriveTitle(doing, repo, pid) {
148
+ function parseTimestamp(value) {
149
+ if (typeof value === "number" && Number.isFinite(value)) {
150
+ return value < 100000000000 ? value * 1000 : value;
151
+ }
152
+ if (typeof value === "string") {
153
+ const parsed = Date.parse(value);
154
+ if (!Number.isNaN(parsed))
155
+ return parsed;
156
+ }
157
+ return undefined;
158
+ }
159
+ function coerceNumber(value) {
160
+ if (typeof value === "number" && Number.isFinite(value))
161
+ return value;
162
+ if (typeof value === "string") {
163
+ const parsed = Number(value);
164
+ if (!Number.isNaN(parsed))
165
+ return parsed;
166
+ }
167
+ return undefined;
168
+ }
169
+ function deriveTitle(doing, repo, pid, kind) {
98
170
  if (doing) {
99
171
  const trimmed = doing.trim();
100
172
  if (trimmed.startsWith("cmd:"))
@@ -110,7 +182,8 @@ function deriveTitle(doing, repo, pid) {
110
182
  }
111
183
  if (repo)
112
184
  return repo;
113
- return `codex#${pid}`;
185
+ const prefix = kind.startsWith("opencode") ? "opencode" : "codex";
186
+ return `${prefix}#${pid}`;
114
187
  }
115
188
  function sanitizeSummary(summary) {
116
189
  if (!summary)
@@ -146,6 +219,35 @@ async function getCwdsForPids(pids) {
146
219
  if (cwd)
147
220
  result.set(pid, cwd);
148
221
  }
222
+ if (result.size > 0) {
223
+ return result;
224
+ }
225
+ }
226
+ catch {
227
+ // fall through to lsof
228
+ }
229
+ try {
230
+ const { stdout } = await execFileAsync("lsof", [
231
+ "-a",
232
+ "-p",
233
+ pids.join(","),
234
+ "-d",
235
+ "cwd",
236
+ "-Fn",
237
+ ]);
238
+ let currentPid = null;
239
+ const lines = stdout.split(/\r?\n/).filter(Boolean);
240
+ for (const line of lines) {
241
+ if (line.startsWith("p")) {
242
+ const pid = Number(line.slice(1));
243
+ currentPid = Number.isNaN(pid) ? null : pid;
244
+ }
245
+ else if (line.startsWith("n") && currentPid) {
246
+ const cwd = line.slice(1).trim();
247
+ if (cwd)
248
+ result.set(currentPid, cwd);
249
+ }
250
+ }
149
251
  }
150
252
  catch {
151
253
  return result;
@@ -210,7 +312,11 @@ export async function scanCodexProcesses() {
210
312
  }
211
313
  const processes = await psList();
212
314
  const codexProcs = processes.filter((proc) => isCodexProcess(proc.cmd, proc.name, matchRe));
213
- const pids = codexProcs.map((proc) => proc.pid);
315
+ const codexPidSet = new Set(codexProcs.map((proc) => proc.pid));
316
+ const opencodeProcs = processes
317
+ .filter((proc) => isOpenCodeProcess(proc.cmd, proc.name))
318
+ .filter((proc) => !codexPidSet.has(proc.pid));
319
+ const pids = Array.from(new Set([...codexProcs, ...opencodeProcs].map((proc) => proc.pid)));
214
320
  let usage = {};
215
321
  try {
216
322
  usage = (await pidusage(pids));
@@ -222,6 +328,31 @@ export async function scanCodexProcesses() {
222
328
  const startTimes = await getStartTimesForPids(pids);
223
329
  const codexHome = resolveCodexHome();
224
330
  const sessions = await listRecentSessions(codexHome);
331
+ const opencodeHost = process.env.CONSENSUS_OPENCODE_HOST || "127.0.0.1";
332
+ const opencodePort = Number(process.env.CONSENSUS_OPENCODE_PORT || 4096);
333
+ const opencodeResult = await getOpenCodeSessions(opencodeHost, opencodePort, {
334
+ silent: true,
335
+ });
336
+ await ensureOpenCodeServer(opencodeHost, opencodePort, opencodeResult);
337
+ if (opencodeProcs.length) {
338
+ ensureOpenCodeEventStream(opencodeHost, opencodePort);
339
+ }
340
+ const opencodeSessions = opencodeResult.ok ? opencodeResult.sessions : [];
341
+ const opencodeApiAvailable = opencodeResult.ok;
342
+ const opencodeSessionsByPid = new Map();
343
+ const opencodeSessionsByDir = new Map();
344
+ for (const session of opencodeSessions) {
345
+ const pid = coerceNumber(session.pid);
346
+ if (typeof pid === "number") {
347
+ opencodeSessionsByPid.set(pid, session);
348
+ }
349
+ if (typeof session.directory === "string") {
350
+ opencodeSessionsByDir.set(session.directory, session);
351
+ }
352
+ if (typeof session.cwd === "string") {
353
+ opencodeSessionsByDir.set(session.cwd, session);
354
+ }
355
+ }
225
356
  const agents = [];
226
357
  const seenIds = new Set();
227
358
  for (const proc of codexProcs) {
@@ -287,7 +418,7 @@ export async function scanCodexProcesses() {
287
418
  const cmdShort = shortenCmd(cmd);
288
419
  const kind = inferKind(cmd);
289
420
  const startedAt = startMs ? Math.floor(startMs / 1000) : undefined;
290
- const computedTitle = title || deriveTitle(doing, repoName, proc.pid);
421
+ const computedTitle = title || deriveTitle(doing, repoName, proc.pid, kind);
291
422
  const safeSummary = sanitizeSummary(summary);
292
423
  agents.push({
293
424
  id,
@@ -310,6 +441,124 @@ export async function scanCodexProcesses() {
310
441
  events,
311
442
  });
312
443
  }
444
+ for (const proc of opencodeProcs) {
445
+ const stats = usage[proc.pid] || {};
446
+ const cpu = typeof stats.cpu === "number" ? stats.cpu : 0;
447
+ const mem = typeof stats.memory === "number" ? stats.memory : 0;
448
+ const elapsed = stats.elapsed;
449
+ const startMs = typeof elapsed === "number"
450
+ ? Date.now() - elapsed
451
+ : startTimes.get(proc.pid);
452
+ const cmdRaw = proc.cmd || proc.name || "";
453
+ const sessionByPid = opencodeSessionsByPid.get(proc.pid);
454
+ const cwdMatch = cwds.get(proc.pid);
455
+ const sessionByDir = sessionByPid
456
+ ? undefined
457
+ : opencodeSessionsByDir.get(cwdMatch || "");
458
+ const session = sessionByPid || sessionByDir;
459
+ const storageSession = !session && cwdMatch ? await getOpenCodeSessionForDirectory(cwdMatch) : undefined;
460
+ const sessionId = session?.id || storageSession?.id || extractOpenCodeSessionId(cmdRaw);
461
+ const sessionTitle = normalizeTitle(session?.title || session?.name || storageSession?.title);
462
+ const sessionCwd = session?.cwd || session?.directory || storageSession?.directory;
463
+ const cwdRaw = sessionCwd || cwds.get(proc.pid);
464
+ const cwd = redactText(cwdRaw) || cwdRaw;
465
+ const repoRoot = cwdRaw ? findRepoRoot(cwdRaw) : null;
466
+ const repoName = repoRoot ? path.basename(repoRoot) : undefined;
467
+ let lastEventAt = parseTimestamp(session?.lastActivity ||
468
+ session?.lastActivityAt ||
469
+ storageSession?.time?.updated ||
470
+ storageSession?.time?.created ||
471
+ session?.time?.updated ||
472
+ session?.time?.created ||
473
+ session?.updatedAt ||
474
+ session?.updated ||
475
+ session?.createdAt ||
476
+ session?.created);
477
+ const statusRaw = typeof session?.status === "string" ? session.status : undefined;
478
+ const status = statusRaw?.toLowerCase();
479
+ const statusIsError = !!status && /error|failed|failure/.test(status);
480
+ const statusIsActive = !!status && /running|active|processing/.test(status);
481
+ const statusIsIdle = !!status && /idle|stopped|paused/.test(status);
482
+ let hasError = statusIsError;
483
+ let inFlight = statusIsActive;
484
+ const model = typeof session?.model === "string" ? session.model : undefined;
485
+ let doing = sessionTitle;
486
+ let summary;
487
+ let events;
488
+ const eventActivity = getOpenCodeActivityBySession(sessionId) || getOpenCodeActivityByPid(proc.pid);
489
+ if (eventActivity) {
490
+ events = eventActivity.events;
491
+ summary = eventActivity.summary || summary;
492
+ lastEventAt = eventActivity.lastEventAt || lastEventAt;
493
+ if (eventActivity.hasError)
494
+ hasError = true;
495
+ if (eventActivity.inFlight)
496
+ inFlight = true;
497
+ if (eventActivity.summary?.current)
498
+ doing = eventActivity.summary.current;
499
+ }
500
+ if (!doing) {
501
+ doing =
502
+ parseDoingFromCmd(proc.cmd || "") || shortenCmd(proc.cmd || proc.name || "");
503
+ }
504
+ if (doing)
505
+ summary = { current: doing };
506
+ const id = `${proc.pid}`;
507
+ const cached = activityCache.get(id);
508
+ const activity = deriveStateWithHold({
509
+ cpu,
510
+ hasError,
511
+ lastEventAt,
512
+ inFlight,
513
+ previousActiveAt: cached?.lastActiveAt,
514
+ now,
515
+ });
516
+ let state = activity.state;
517
+ if (statusIsError)
518
+ state = "error";
519
+ else if (statusIsActive)
520
+ state = "active";
521
+ else if (statusIsIdle)
522
+ state = "idle";
523
+ const hasSignal = statusIsActive ||
524
+ statusIsIdle ||
525
+ statusIsError ||
526
+ typeof lastEventAt === "number" ||
527
+ !!inFlight;
528
+ if (!opencodeApiAvailable && !hasSignal)
529
+ state = "idle";
530
+ const cpuThreshold = Number(process.env.CONSENSUS_CPU_ACTIVE || 1);
531
+ if (!hasSignal && cpu <= cpuThreshold) {
532
+ state = "idle";
533
+ }
534
+ activityCache.set(id, { lastActiveAt: state === "active" ? activity.lastActiveAt : undefined, lastSeenAt: now });
535
+ seenIds.add(id);
536
+ const cmd = redactText(cmdRaw) || cmdRaw;
537
+ const cmdShort = shortenCmd(cmd);
538
+ const kind = inferKind(cmd);
539
+ const startedAt = startMs ? Math.floor(startMs / 1000) : undefined;
540
+ const computedTitle = sessionTitle || deriveTitle(doing, repoName, proc.pid, kind);
541
+ const safeSummary = sanitizeSummary(summary);
542
+ agents.push({
543
+ id,
544
+ pid: proc.pid,
545
+ startedAt,
546
+ lastEventAt,
547
+ title: redactText(computedTitle) || computedTitle,
548
+ cmd,
549
+ cmdShort,
550
+ kind,
551
+ cpu,
552
+ mem,
553
+ state,
554
+ doing: redactText(doing) || doing,
555
+ repo: repoName,
556
+ cwd,
557
+ model,
558
+ summary: safeSummary,
559
+ events,
560
+ });
561
+ }
313
562
  for (const id of activityCache.keys()) {
314
563
  if (!seenIds.has(id)) {
315
564
  activityCache.delete(id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "consensus-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/public/app.js CHANGED
@@ -9,8 +9,10 @@ const panelClose = document.getElementById("panel-close");
9
9
  const statusEl = document.getElementById("status");
10
10
  const countEl = document.getElementById("count");
11
11
  const activeList = document.getElementById("active-list");
12
+ const serverList = document.getElementById("server-list");
12
13
  const searchInput = document.getElementById("search");
13
14
  const laneTitle = document.querySelector(".lane-title");
15
+ const serverTitle = document.querySelector(".server-title");
14
16
 
15
17
  const tileW = 96;
16
18
  const tileH = 48;
@@ -24,6 +26,11 @@ const statePalette = {
24
26
  idle: { top: "#384a57", left: "#2b3943", right: "#25323b", stroke: "#4f6b7a" },
25
27
  error: { top: "#82443c", left: "#6d3530", right: "#5a2c28", stroke: "#d1584b" },
26
28
  };
29
+ const serverPalette = {
30
+ active: { top: "#7d6a2b", left: "#665725", right: "#54481f", stroke: "#f5c453" },
31
+ idle: { top: "#353b42", left: "#272c33", right: "#1f242a", stroke: "#6b7380" },
32
+ error: statePalette.error,
33
+ };
27
34
  const stateOpacity = {
28
35
  active: 1,
29
36
  idle: 0.35,
@@ -119,12 +126,16 @@ function hashString(input) {
119
126
  return Math.abs(hash);
120
127
  }
121
128
 
122
- function keyForAgent(agent) {
129
+ function groupKeyForAgent(agent) {
123
130
  return agent.repo || agent.cwd || agent.cmd || agent.id;
124
131
  }
125
132
 
126
- function assignCoordinate(key) {
127
- const hash = hashString(key);
133
+ function keyForAgent(agent) {
134
+ return `${groupKeyForAgent(agent)}::${agent.id}`;
135
+ }
136
+
137
+ function assignCoordinate(key, baseKey) {
138
+ const hash = hashString(baseKey || key);
128
139
  const baseX = (hash % 16) - 8;
129
140
  const baseY = ((hash >> 4) % 16) - 8;
130
141
  const maxRadius = 20;
@@ -152,9 +163,10 @@ function updateLayout(newAgents) {
152
163
  const activeKeys = new Set();
153
164
  for (const agent of newAgents) {
154
165
  const key = keyForAgent(agent);
166
+ const baseKey = groupKeyForAgent(agent);
155
167
  activeKeys.add(key);
156
168
  if (!layout.has(key)) {
157
- assignCoordinate(key);
169
+ assignCoordinate(key, baseKey);
158
170
  }
159
171
  }
160
172
 
@@ -170,8 +182,14 @@ function setStatus(text) {
170
182
  statusEl.textContent = text;
171
183
  }
172
184
 
173
- function setCount(count) {
174
- countEl.textContent = `${count} agent${count === 1 ? "" : "s"}`;
185
+ function setCount(agentCount, serverCount) {
186
+ const agentLabel = `${agentCount} agent${agentCount === 1 ? "" : "s"}`;
187
+ if (typeof serverCount === "number") {
188
+ const serverLabel = `${serverCount} server${serverCount === 1 ? "" : "s"}`;
189
+ countEl.textContent = `${agentLabel} • ${serverLabel}`;
190
+ return;
191
+ }
192
+ countEl.textContent = agentLabel;
175
193
  }
176
194
 
177
195
  function formatBytes(bytes) {
@@ -200,6 +218,26 @@ function escapeHtml(value) {
200
218
  .replace(/'/g, "&#39;");
201
219
  }
202
220
 
221
+ function isServerKind(kind) {
222
+ return kind === "app-server" || kind === "opencode-server";
223
+ }
224
+
225
+ function paletteFor(agent) {
226
+ if (isServerKind(agent.kind)) {
227
+ return serverPalette[agent.state] || serverPalette.idle;
228
+ }
229
+ return statePalette[agent.state] || statePalette.idle;
230
+ }
231
+
232
+ function accentFor(agent) {
233
+ return isServerKind(agent.kind) ? "#f5c453" : "#57f2c6";
234
+ }
235
+
236
+ function accentGlow(agent, alpha) {
237
+ const tint = isServerKind(agent.kind) ? "245, 196, 83" : "87, 242, 198";
238
+ return `rgba(${tint}, ${alpha})`;
239
+ }
240
+
203
241
  function labelFor(agent) {
204
242
  if (agent.title) return agent.title;
205
243
  if (agent.repo) return agent.repo;
@@ -267,10 +305,10 @@ function drawTag(ctx, x, y, text, accent) {
267
305
  ctx.restore();
268
306
  }
269
307
 
270
- function renderActiveList(items) {
271
- if (!activeList) return;
308
+ function renderLaneList(items, container, emptyLabel) {
309
+ if (!container) return;
272
310
  if (!items.length) {
273
- activeList.innerHTML = "<div class=\"lane-meta\">No active agents.</div>";
311
+ container.innerHTML = `<div class="lane-meta">${emptyLabel}</div>`;
274
312
  return;
275
313
  }
276
314
  const sorted = [...items].sort((a, b) => {
@@ -281,7 +319,7 @@ function renderActiveList(items) {
281
319
  return b.cpu - a.cpu;
282
320
  });
283
321
 
284
- activeList.innerHTML = sorted
322
+ container.innerHTML = sorted
285
323
  .map((agent) => {
286
324
  const doingRaw = agent.summary?.current || agent.doing || agent.cmdShort || "";
287
325
  const doing = escapeHtml(truncate(doingRaw, 80));
@@ -299,7 +337,7 @@ function renderActiveList(items) {
299
337
  })
300
338
  .join("");
301
339
 
302
- Array.from(activeList.querySelectorAll(".lane-item")).forEach((item) => {
340
+ Array.from(container.querySelectorAll(".lane-item")).forEach((item) => {
303
341
  item.addEventListener("click", () => {
304
342
  const id = item.getAttribute("data-id");
305
343
  selected = sorted.find((agent) => agent.id === id) || null;
@@ -446,10 +484,14 @@ function draw() {
446
484
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
447
485
 
448
486
  for (const item of drawList) {
449
- const palette = statePalette[item.agent.state] || statePalette.idle;
487
+ const palette = paletteFor(item.agent);
450
488
  const memMB = item.agent.mem / (1024 * 1024);
451
489
  const heightBase = Math.min(120, Math.max(18, memMB * 0.4));
452
490
  const isActive = item.agent.state === "active";
491
+ const isServer = isServerKind(item.agent.kind);
492
+ const accent = accentFor(item.agent);
493
+ const accentStrong = isServer ? "rgba(245, 196, 83, 0.6)" : "rgba(87, 242, 198, 0.6)";
494
+ const accentSoft = isServer ? "rgba(245, 196, 83, 0.35)" : "rgba(87, 242, 198, 0.35)";
453
495
  const pulse =
454
496
  isActive && !reducedMotion
455
497
  ? 4 + Math.sin(time / 200) * 3
@@ -490,7 +532,7 @@ function draw() {
490
532
  y + tileH * 0.02,
491
533
  tileW * 0.92,
492
534
  tileH * 0.46,
493
- `rgba(87, 242, 198, ${glowAlpha})`,
535
+ accentGlow(item.agent, glowAlpha),
494
536
  null
495
537
  );
496
538
  drawDiamond(
@@ -499,14 +541,14 @@ function draw() {
499
541
  y - height - tileH * 0.18,
500
542
  roofSize * 0.82,
501
543
  roofSize * 0.42,
502
- `rgba(87, 242, 198, ${capAlpha})`,
544
+ accentGlow(item.agent, capAlpha),
503
545
  null
504
546
  );
505
547
  ctx.restore();
506
548
  }
507
549
 
508
550
  if (selected && selected.id === item.agent.id) {
509
- drawDiamond(ctx, x, y, tileW + 10, tileH + 6, "rgba(0,0,0,0)", "#57f2c6");
551
+ drawDiamond(ctx, x, y, tileW + 10, tileH + 6, "rgba(0,0,0,0)", accent);
510
552
  }
511
553
 
512
554
  ctx.fillStyle = "rgba(10, 12, 15, 0.6)";
@@ -519,15 +561,21 @@ function draw() {
519
561
  const showActiveTag = topActiveIds.has(item.agent.id);
520
562
  if (isHovered || isSelected) {
521
563
  const label = truncate(labelFor(item.agent), 20);
522
- drawTag(ctx, x, y - height - tileH * 0.6, label, "rgba(87, 242, 198, 0.6)");
564
+ drawTag(ctx, x, y - height - tileH * 0.6, label, accentStrong);
523
565
  const doing = truncate(item.agent.summary?.current || item.agent.doing || "", 36);
524
- drawTag(ctx, x, y - height - tileH * 0.9, doing, "rgba(87, 242, 198, 0.35)");
566
+ drawTag(ctx, x, y - height - tileH * 0.9, doing, accentSoft);
567
+ if (isServer) {
568
+ drawTag(ctx, x, y + tileH * 0.2, "server", "rgba(79, 107, 122, 0.6)");
569
+ }
525
570
  } else if (showActiveTag) {
526
571
  const doing = truncate(
527
572
  item.agent.summary?.current || item.agent.doing || labelFor(item.agent),
528
573
  32
529
574
  );
530
- drawTag(ctx, x, y - height - tileH * 0.7, doing, "rgba(87, 242, 198, 0.35)");
575
+ drawTag(ctx, x, y - height - tileH * 0.7, doing, accentSoft);
576
+ if (isServer) {
577
+ drawTag(ctx, x, y + tileH * 0.2, "server", "rgba(79, 107, 122, 0.6)");
578
+ }
531
579
  }
532
580
 
533
581
  hitList.push({
@@ -680,7 +728,9 @@ function connect() {
680
728
 
681
729
  function applySnapshot(payload) {
682
730
  agents = payload.agents || [];
683
- setCount(agents.length);
731
+ const serverAgents = agents.filter((agent) => isServerKind(agent.kind));
732
+ const agentNodes = agents.filter((agent) => !isServerKind(agent.kind));
733
+ setCount(agentNodes.length, serverAgents.length);
684
734
  const query = searchQuery.trim().toLowerCase();
685
735
  searchMatches = new Set(
686
736
  query ? agents.filter((agent) => matchesQuery(agent, query)).map((agent) => agent.id) : []
@@ -689,12 +739,19 @@ function applySnapshot(payload) {
689
739
  ? agents.filter((agent) => searchMatches.has(agent.id))
690
740
  : agents;
691
741
  const listAgents = query
692
- ? visibleAgents
693
- : visibleAgents.filter((agent) => agent.state !== "idle");
742
+ ? visibleAgents.filter((agent) => !isServerKind(agent.kind))
743
+ : visibleAgents.filter((agent) => agent.state !== "idle" && !isServerKind(agent.kind));
744
+ const listServers = query
745
+ ? visibleAgents.filter((agent) => isServerKind(agent.kind))
746
+ : visibleAgents.filter((agent) => isServerKind(agent.kind));
694
747
  if (laneTitle) {
695
748
  laneTitle.textContent = query ? "search results" : "active agents";
696
749
  }
697
- renderActiveList(listAgents);
750
+ if (serverTitle) {
751
+ serverTitle.textContent = query ? "server results" : "servers";
752
+ }
753
+ renderLaneList(listAgents, activeList, "No active agents.");
754
+ renderLaneList(listServers, serverList, "No servers detected.");
698
755
  if (selected) {
699
756
  selected = agents.find((agent) => agent.id === selected.id) || selected;
700
757
  renderPanel(selected);
package/public/index.html CHANGED
@@ -41,6 +41,9 @@
41
41
  aria-label="Search metadata"
42
42
  />
43
43
  <div id="active-list"></div>
44
+ <div class="lane-divider" aria-hidden="true"></div>
45
+ <div class="lane-title server-title">servers</div>
46
+ <div id="server-list"></div>
44
47
  </div>
45
48
  <div id="tooltip" class="hidden"></div>
46
49
  <aside id="panel" class="collapsed" aria-label="Agent details">
package/public/style.css CHANGED
@@ -282,6 +282,18 @@ body {
282
282
  gap: 8px;
283
283
  }
284
284
 
285
+ #server-list {
286
+ display: flex;
287
+ flex-direction: column;
288
+ gap: 8px;
289
+ }
290
+
291
+ .lane-divider {
292
+ height: 1px;
293
+ margin: 12px 0;
294
+ background: rgba(62, 78, 89, 0.6);
295
+ }
296
+
285
297
  .lane-item {
286
298
  display: flex;
287
299
  align-items: flex-start;