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 +10 -0
- package/README.md +4 -0
- package/dist/cli.js +12 -0
- package/dist/opencodeApi.js +84 -0
- package/dist/opencodeEvents.js +359 -0
- package/dist/opencodeServer.js +91 -0
- package/dist/opencodeStorage.js +127 -0
- package/dist/scan.js +254 -5
- package/package.json +1 -1
- package/public/app.js +79 -22
- package/public/index.html +3 -0
- package/public/style.css +12 -0
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
|
|
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
|
-
|
|
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
|
|
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
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
|
|
129
|
+
function groupKeyForAgent(agent) {
|
|
123
130
|
return agent.repo || agent.cwd || agent.cmd || agent.id;
|
|
124
131
|
}
|
|
125
132
|
|
|
126
|
-
function
|
|
127
|
-
|
|
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(
|
|
174
|
-
|
|
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, "'");
|
|
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
|
|
271
|
-
if (!
|
|
308
|
+
function renderLaneList(items, container, emptyLabel) {
|
|
309
|
+
if (!container) return;
|
|
272
310
|
if (!items.length) {
|
|
273
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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)",
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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;
|