consensus-cli 0.1.4 → 0.1.5
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 +6 -0
- package/README.md +18 -10
- package/dist/claudeCli.js +125 -0
- package/dist/codexLogs.js +34 -13
- package/dist/opencodeEvents.js +44 -15
- package/dist/opencodeState.js +36 -0
- package/dist/scan.js +106 -19
- package/package.json +1 -1
- package/public/app.js +80 -18
- package/public/style.css +9 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ This project follows Semantic Versioning.
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.1.5 - 2026-01-25
|
|
9
|
+
- Add Claude Code process detection with prompt/resume parsing.
|
|
10
|
+
- Apply CLI-specific palettes (Codex/OpenCode/Claude Code) across tiles and lane items.
|
|
11
|
+
- Add Claude CLI parsing unit tests.
|
|
12
|
+
- Update README with Claude Code support.
|
|
13
|
+
|
|
8
14
|
## 0.1.4 - 2026-01-24
|
|
9
15
|
- Fix OpenCode event tracking build error (pid activity typing).
|
|
10
16
|
|
package/README.md
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# consensus-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/consensus-cli)
|
|
4
|
+
[](https://github.com/integrate-your-mind/consensus-cli/releases)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Live isometric atlas for Codex, OpenCode, and Claude Code sessions, rendered in a local browser.
|
|
4
8
|
|
|
5
9
|
## Status
|
|
6
10
|
Beta. Local-only, no hosted service.
|
|
7
11
|
|
|
8
12
|
## Who it's for
|
|
9
|
-
Developers running multiple Codex sessions who want a visual, at-a-glance view of activity.
|
|
13
|
+
Developers running multiple Codex, OpenCode, or Claude Code sessions who want a visual, at-a-glance view of activity.
|
|
10
14
|
|
|
11
15
|
## Core use cases
|
|
12
16
|
- Track which agents are active right now.
|
|
@@ -15,7 +19,7 @@ Developers running multiple Codex sessions who want a visual, at-a-glance view o
|
|
|
15
19
|
|
|
16
20
|
## Scope (non-goals)
|
|
17
21
|
- Does not start, stop, or manage processes.
|
|
18
|
-
- Does not connect to remote Codex instances.
|
|
22
|
+
- Does not connect to remote Codex, OpenCode, or Claude Code instances.
|
|
19
23
|
- No authentication or multi-user access.
|
|
20
24
|
|
|
21
25
|
## Quickstart
|
|
@@ -27,6 +31,8 @@ npm run dev
|
|
|
27
31
|
The server prints the local URL (default `http://127.0.0.1:8787`).
|
|
28
32
|
Consensus reads local Codex CLI sessions and does not require API keys.
|
|
29
33
|
You just need Codex CLI installed and signed in (Pro subscription or team plan).
|
|
34
|
+
If OpenCode is installed, Consensus will auto-start its local server.
|
|
35
|
+
If Claude Code is installed, it will appear automatically (run `claude` once to sign in).
|
|
30
36
|
|
|
31
37
|
## Run via npx
|
|
32
38
|
```bash
|
|
@@ -39,17 +45,19 @@ consensus dev server running on http://127.0.0.1:8787
|
|
|
39
45
|
```
|
|
40
46
|
|
|
41
47
|
## What you get
|
|
42
|
-
- One tile per running `codex` process.
|
|
48
|
+
- One tile per running `codex`, `opencode`, or `claude` process.
|
|
43
49
|
- Activity state (active/idle/error) from CPU and recent events.
|
|
44
|
-
- Best-effort "doing" summary from Codex session JSONL.
|
|
50
|
+
- Best-effort "doing" summary from Codex session JSONL, OpenCode events, or Claude CLI flags.
|
|
45
51
|
- Click a tile for details and recent events.
|
|
46
|
-
- Active lane
|
|
52
|
+
- Active lane for agents plus a dedicated lane for servers.
|
|
47
53
|
|
|
48
54
|
## How it works
|
|
49
|
-
1) Scan OS process list for Codex.
|
|
50
|
-
2) Resolve
|
|
51
|
-
3)
|
|
52
|
-
4)
|
|
55
|
+
1) Scan OS process list for Codex + OpenCode + Claude Code.
|
|
56
|
+
2) Resolve Codex session JSONL under `CODEX_HOME/sessions/`.
|
|
57
|
+
3) Query the OpenCode local server API and event stream (with storage fallback).
|
|
58
|
+
4) Read Claude Code CLI flags to infer current work.
|
|
59
|
+
5) Poll and push snapshots over WebSocket.
|
|
60
|
+
6) Render tiles on a canvas with isometric projection.
|
|
53
61
|
|
|
54
62
|
## Install options
|
|
55
63
|
- Local dev: `npm install` + `npm run dev`
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { deriveStateWithHold } from "./activity.js";
|
|
2
|
+
const CLAUDE_BINARIES = new Set(["claude", "claude.exe"]);
|
|
3
|
+
export function splitArgs(command) {
|
|
4
|
+
if (!command)
|
|
5
|
+
return [];
|
|
6
|
+
const args = [];
|
|
7
|
+
const regex = /"([^"]*)"|'([^']*)'|\S+/g;
|
|
8
|
+
let match;
|
|
9
|
+
while ((match = regex.exec(command)) !== null) {
|
|
10
|
+
const token = match[1] ?? match[2] ?? match[0];
|
|
11
|
+
if (token)
|
|
12
|
+
args.push(token);
|
|
13
|
+
}
|
|
14
|
+
return args;
|
|
15
|
+
}
|
|
16
|
+
function findClaudeIndex(parts) {
|
|
17
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
18
|
+
const base = parts[i] ? parts[i].split(/[/\\]/).pop() || "" : "";
|
|
19
|
+
if (CLAUDE_BINARIES.has(base))
|
|
20
|
+
return i;
|
|
21
|
+
}
|
|
22
|
+
return -1;
|
|
23
|
+
}
|
|
24
|
+
function readFlagValue(parts, flag) {
|
|
25
|
+
const idx = parts.indexOf(flag);
|
|
26
|
+
if (idx === -1)
|
|
27
|
+
return undefined;
|
|
28
|
+
const value = parts[idx + 1];
|
|
29
|
+
if (!value || value.startsWith("-"))
|
|
30
|
+
return undefined;
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function findPrompt(parts, startIndex) {
|
|
34
|
+
for (let i = startIndex; i < parts.length; i += 1) {
|
|
35
|
+
const part = parts[i];
|
|
36
|
+
if (!part)
|
|
37
|
+
continue;
|
|
38
|
+
if (part === "-p" || part === "--print") {
|
|
39
|
+
const next = parts[i + 1];
|
|
40
|
+
if (next && !next.startsWith("-"))
|
|
41
|
+
return next;
|
|
42
|
+
}
|
|
43
|
+
if (part.startsWith("-")) {
|
|
44
|
+
const skipFlags = new Set([
|
|
45
|
+
"--output-format",
|
|
46
|
+
"--input-format",
|
|
47
|
+
"--model",
|
|
48
|
+
"--max-turns",
|
|
49
|
+
"--max-budget-usd",
|
|
50
|
+
"--tools",
|
|
51
|
+
"--allowedTools",
|
|
52
|
+
"--disallowedTools",
|
|
53
|
+
"--resume",
|
|
54
|
+
"-r",
|
|
55
|
+
"--session-id",
|
|
56
|
+
"--continue",
|
|
57
|
+
"-c",
|
|
58
|
+
]);
|
|
59
|
+
if (skipFlags.has(part)) {
|
|
60
|
+
i += 1;
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
return part;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
export function parseClaudeCommand(command) {
|
|
69
|
+
const parts = splitArgs(command);
|
|
70
|
+
const claudeIndex = findClaudeIndex(parts);
|
|
71
|
+
if (claudeIndex === -1)
|
|
72
|
+
return null;
|
|
73
|
+
const hasPrint = parts.includes("-p") || parts.includes("--print");
|
|
74
|
+
const continued = parts.includes("--continue") || parts.includes("-c");
|
|
75
|
+
const resume = readFlagValue(parts, "--resume") || readFlagValue(parts, "-r");
|
|
76
|
+
const model = readFlagValue(parts, "--model");
|
|
77
|
+
const prompt = findPrompt(parts, claudeIndex + 1);
|
|
78
|
+
return {
|
|
79
|
+
kind: hasPrint ? "claude-cli" : "claude-tui",
|
|
80
|
+
prompt,
|
|
81
|
+
resume,
|
|
82
|
+
continued,
|
|
83
|
+
model,
|
|
84
|
+
print: hasPrint,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function summarizeClaudeCommand(command) {
|
|
88
|
+
const info = parseClaudeCommand(command);
|
|
89
|
+
if (!info)
|
|
90
|
+
return null;
|
|
91
|
+
if (info.prompt) {
|
|
92
|
+
return { ...info, doing: `prompt: ${info.prompt}` };
|
|
93
|
+
}
|
|
94
|
+
if (info.resume) {
|
|
95
|
+
return { ...info, doing: `resume: ${info.resume}` };
|
|
96
|
+
}
|
|
97
|
+
if (info.continued) {
|
|
98
|
+
return { ...info, doing: "continue" };
|
|
99
|
+
}
|
|
100
|
+
if (info.print) {
|
|
101
|
+
return { ...info, doing: "claude print" };
|
|
102
|
+
}
|
|
103
|
+
return { ...info, doing: "claude" };
|
|
104
|
+
}
|
|
105
|
+
export function deriveClaudeState(input) {
|
|
106
|
+
const info = input.info ?? null;
|
|
107
|
+
const baseThreshold = input.cpuThreshold ??
|
|
108
|
+
Number(process.env.CONSENSUS_CLAUDE_CPU_ACTIVE || process.env.CONSENSUS_CPU_ACTIVE || 1);
|
|
109
|
+
const hasWork = !!info?.prompt || !!info?.resume || !!info?.continued;
|
|
110
|
+
const isTui = info?.kind === "claude-tui";
|
|
111
|
+
const effectiveThreshold = isTui && !hasWork ? baseThreshold * 3 : baseThreshold;
|
|
112
|
+
const result = deriveStateWithHold({
|
|
113
|
+
cpu: input.cpu,
|
|
114
|
+
hasError: false,
|
|
115
|
+
lastEventAt: undefined,
|
|
116
|
+
inFlight: hasWork,
|
|
117
|
+
previousActiveAt: input.previousActiveAt,
|
|
118
|
+
now: input.now,
|
|
119
|
+
cpuThreshold: effectiveThreshold,
|
|
120
|
+
});
|
|
121
|
+
if (!hasWork && input.cpu <= effectiveThreshold) {
|
|
122
|
+
return { state: "idle", lastActiveAt: undefined };
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
package/dist/codexLogs.js
CHANGED
|
@@ -266,6 +266,8 @@ export async function updateTail(sessionPath) {
|
|
|
266
266
|
partial: "",
|
|
267
267
|
events: [],
|
|
268
268
|
};
|
|
269
|
+
const prevMtime = state.lastMtimeMs;
|
|
270
|
+
state.lastMtimeMs = stat.mtimeMs;
|
|
269
271
|
if (stat.size < state.offset) {
|
|
270
272
|
state.offset = 0;
|
|
271
273
|
state.partial = "";
|
|
@@ -313,15 +315,15 @@ export async function updateTail(sessionPath) {
|
|
|
313
315
|
state.partial = lines.pop() || "";
|
|
314
316
|
const startRe = /(turn|item|response)\.started/i;
|
|
315
317
|
const endRe = /(turn|item|response)\.(completed|failed|errored)/i;
|
|
316
|
-
|
|
318
|
+
const processLine = (line) => {
|
|
317
319
|
if (!line.trim())
|
|
318
|
-
|
|
320
|
+
return false;
|
|
319
321
|
let ev;
|
|
320
322
|
try {
|
|
321
323
|
ev = JSON.parse(line);
|
|
322
324
|
}
|
|
323
325
|
catch {
|
|
324
|
-
|
|
326
|
+
return false;
|
|
325
327
|
}
|
|
326
328
|
const ts = getEventTimestamp(ev);
|
|
327
329
|
const { summary, kind, isError, model, type } = summarizeEvent(ev);
|
|
@@ -357,20 +359,39 @@ export async function updateTail(sessionPath) {
|
|
|
357
359
|
if (state.events.length > MAX_EVENTS) {
|
|
358
360
|
state.events = state.events.slice(-MAX_EVENTS);
|
|
359
361
|
}
|
|
362
|
+
return true;
|
|
360
363
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
364
|
+
state.lastEventAt = Math.max(state.lastEventAt || 0, ts);
|
|
365
|
+
if (isError) {
|
|
366
|
+
state.lastError = {
|
|
367
|
+
ts,
|
|
368
|
+
type: typeof type === "string" ? type : "event",
|
|
369
|
+
summary: "error",
|
|
370
|
+
isError,
|
|
371
|
+
};
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
return true;
|
|
375
|
+
};
|
|
376
|
+
let parsedAny = false;
|
|
377
|
+
for (const line of lines) {
|
|
378
|
+
if (processLine(line))
|
|
379
|
+
parsedAny = true;
|
|
380
|
+
}
|
|
381
|
+
const candidate = state.partial.trim();
|
|
382
|
+
if (candidate.startsWith("{") && candidate.endsWith("}")) {
|
|
383
|
+
if (processLine(candidate)) {
|
|
384
|
+
parsedAny = true;
|
|
385
|
+
state.partial = "";
|
|
371
386
|
}
|
|
372
387
|
}
|
|
373
388
|
state.offset = stat.size;
|
|
389
|
+
if (!state.lastEventAt && typeof stat.mtimeMs === "number") {
|
|
390
|
+
state.lastEventAt = stat.mtimeMs;
|
|
391
|
+
}
|
|
392
|
+
else if (stat.size > prevOffset && prevMtime && prevMtime !== stat.mtimeMs) {
|
|
393
|
+
state.lastEventAt = Math.max(state.lastEventAt || 0, stat.mtimeMs);
|
|
394
|
+
}
|
|
374
395
|
tailStates.set(sessionPath, state);
|
|
375
396
|
return state;
|
|
376
397
|
}
|
package/dist/opencodeEvents.js
CHANGED
|
@@ -120,7 +120,7 @@ function summarizeEvent(raw) {
|
|
|
120
120
|
raw?.properties?.file;
|
|
121
121
|
if (typeof pathHint === "string" && pathHint.trim() && lowerType.includes("file")) {
|
|
122
122
|
const summary = redactText(`edit: ${pathHint.trim()}`) || `edit: ${pathHint.trim()}`;
|
|
123
|
-
return { summary, kind: "edit", isError, type };
|
|
123
|
+
return { summary, kind: "edit", isError, type, inFlight };
|
|
124
124
|
}
|
|
125
125
|
const tool = raw?.tool ||
|
|
126
126
|
raw?.tool_name ||
|
|
@@ -139,7 +139,7 @@ function summarizeEvent(raw) {
|
|
|
139
139
|
const trimmed = promptText.replace(/\s+/g, " ").trim();
|
|
140
140
|
const snippet = trimmed.slice(0, 120);
|
|
141
141
|
const summary = redactText(`prompt: ${snippet}`) || `prompt: ${snippet}`;
|
|
142
|
-
return { summary, kind: "prompt", isError, type };
|
|
142
|
+
return { summary, kind: "prompt", isError, type, inFlight };
|
|
143
143
|
}
|
|
144
144
|
const messageText = extractText(raw?.message) ||
|
|
145
145
|
extractText(raw?.content) ||
|
|
@@ -149,13 +149,13 @@ function summarizeEvent(raw) {
|
|
|
149
149
|
const trimmed = messageText.replace(/\s+/g, " ").trim();
|
|
150
150
|
const snippet = trimmed.slice(0, 80);
|
|
151
151
|
const summary = redactText(snippet) || snippet;
|
|
152
|
-
return { summary, kind: "message", isError, type };
|
|
152
|
+
return { summary, kind: "message", isError, type, inFlight };
|
|
153
153
|
}
|
|
154
154
|
if (type && type !== "event") {
|
|
155
155
|
const summary = redactText(`event: ${type}`) || `event: ${type}`;
|
|
156
|
-
return { summary, kind: "other", isError, type };
|
|
156
|
+
return { summary, kind: "other", isError, type, inFlight };
|
|
157
157
|
}
|
|
158
|
-
return { kind: "other", isError, type };
|
|
158
|
+
return { kind: "other", isError, type, inFlight };
|
|
159
159
|
}
|
|
160
160
|
function ensureActivity(key, map, now) {
|
|
161
161
|
const existing = map.get(key);
|
|
@@ -209,28 +209,57 @@ function handleRawEvent(raw) {
|
|
|
209
209
|
const sessionId = getSessionId(raw);
|
|
210
210
|
const pid = getPid(raw);
|
|
211
211
|
const { summary, kind, isError, type, inFlight } = summarizeEvent(raw);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
212
|
+
const entry = summary
|
|
213
|
+
? {
|
|
214
|
+
ts,
|
|
215
|
+
type: typeof type === "string" ? type : "event",
|
|
216
|
+
summary,
|
|
217
|
+
isError,
|
|
218
|
+
}
|
|
219
|
+
: null;
|
|
220
220
|
const now = nowMs();
|
|
221
221
|
if (sessionId) {
|
|
222
222
|
const state = ensureActivity(sessionId, sessionActivity, now);
|
|
223
|
-
|
|
223
|
+
if (entry) {
|
|
224
|
+
recordEvent(state, entry, kind);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
state.lastEventAt = Math.max(state.lastEventAt || 0, ts);
|
|
228
|
+
if (isError) {
|
|
229
|
+
state.lastError = {
|
|
230
|
+
ts,
|
|
231
|
+
type: typeof type === "string" ? type : "event",
|
|
232
|
+
summary: "error",
|
|
233
|
+
isError,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
224
237
|
if (typeof inFlight === "boolean")
|
|
225
238
|
state.inFlight = inFlight;
|
|
226
239
|
}
|
|
227
240
|
if (typeof pid === "number") {
|
|
228
241
|
const state = ensureActivity(pid, pidActivity, now);
|
|
229
|
-
|
|
242
|
+
if (entry) {
|
|
243
|
+
recordEvent(state, entry, kind);
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
state.lastEventAt = Math.max(state.lastEventAt || 0, ts);
|
|
247
|
+
if (isError) {
|
|
248
|
+
state.lastError = {
|
|
249
|
+
ts,
|
|
250
|
+
type: typeof type === "string" ? type : "event",
|
|
251
|
+
summary: "error",
|
|
252
|
+
isError,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
230
256
|
if (typeof inFlight === "boolean")
|
|
231
257
|
state.inFlight = inFlight;
|
|
232
258
|
}
|
|
233
259
|
}
|
|
260
|
+
export function ingestOpenCodeEvent(raw) {
|
|
261
|
+
handleRawEvent(raw);
|
|
262
|
+
}
|
|
234
263
|
function pruneStale() {
|
|
235
264
|
const cutoff = nowMs() - STALE_TTL_MS;
|
|
236
265
|
for (const [key, state] of sessionActivity.entries()) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { deriveStateWithHold } from "./activity.js";
|
|
2
|
+
export function deriveOpenCodeState(input) {
|
|
3
|
+
const status = input.status?.toLowerCase();
|
|
4
|
+
const statusIsError = !!status && /error|failed|failure/.test(status);
|
|
5
|
+
const statusIsActive = !!status && /running|active|processing/.test(status);
|
|
6
|
+
const statusIsIdle = !!status && /idle|stopped|paused/.test(status);
|
|
7
|
+
const activity = deriveStateWithHold({
|
|
8
|
+
cpu: input.cpu,
|
|
9
|
+
hasError: input.hasError,
|
|
10
|
+
lastEventAt: input.lastEventAt,
|
|
11
|
+
inFlight: input.inFlight,
|
|
12
|
+
previousActiveAt: input.previousActiveAt,
|
|
13
|
+
now: input.now,
|
|
14
|
+
cpuThreshold: input.cpuThreshold,
|
|
15
|
+
eventWindowMs: input.eventWindowMs,
|
|
16
|
+
holdMs: input.holdMs,
|
|
17
|
+
});
|
|
18
|
+
let state = activity.state;
|
|
19
|
+
if (statusIsError) {
|
|
20
|
+
state = "error";
|
|
21
|
+
}
|
|
22
|
+
else if (statusIsIdle) {
|
|
23
|
+
state = "idle";
|
|
24
|
+
}
|
|
25
|
+
else if (statusIsActive && state !== "active") {
|
|
26
|
+
state = "idle";
|
|
27
|
+
}
|
|
28
|
+
const cpuThreshold = input.cpuThreshold ?? Number(process.env.CONSENSUS_CPU_ACTIVE || 1);
|
|
29
|
+
if (input.isServer) {
|
|
30
|
+
state = input.cpu > cpuThreshold ? "active" : "idle";
|
|
31
|
+
}
|
|
32
|
+
if (state === "idle") {
|
|
33
|
+
return { state, lastActiveAt: undefined };
|
|
34
|
+
}
|
|
35
|
+
return { state, lastActiveAt: activity.lastActiveAt };
|
|
36
|
+
}
|
package/dist/scan.js
CHANGED
|
@@ -10,6 +10,8 @@ import { getOpenCodeSessions } from "./opencodeApi.js";
|
|
|
10
10
|
import { ensureOpenCodeServer } from "./opencodeServer.js";
|
|
11
11
|
import { ensureOpenCodeEventStream, getOpenCodeActivityByPid, getOpenCodeActivityBySession, } from "./opencodeEvents.js";
|
|
12
12
|
import { getOpenCodeSessionForDirectory } from "./opencodeStorage.js";
|
|
13
|
+
import { deriveOpenCodeState } from "./opencodeState.js";
|
|
14
|
+
import { deriveClaudeState, summarizeClaudeCommand } from "./claudeCli.js";
|
|
13
15
|
import { redactText } from "./redact.js";
|
|
14
16
|
const execFileAsync = promisify(execFile);
|
|
15
17
|
const repoCache = new Map();
|
|
@@ -44,6 +46,19 @@ function isOpenCodeProcess(cmd, name) {
|
|
|
44
46
|
return true;
|
|
45
47
|
return false;
|
|
46
48
|
}
|
|
49
|
+
function isClaudeProcess(cmd, name) {
|
|
50
|
+
if (!cmd && !name)
|
|
51
|
+
return false;
|
|
52
|
+
if (name === "claude")
|
|
53
|
+
return true;
|
|
54
|
+
if (!cmd)
|
|
55
|
+
return false;
|
|
56
|
+
const firstToken = cmd.trim().split(/\s+/)[0];
|
|
57
|
+
const base = path.basename(firstToken);
|
|
58
|
+
if (base === "claude" || base === "claude.exe")
|
|
59
|
+
return true;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
47
62
|
function inferKind(cmd) {
|
|
48
63
|
if (cmd.includes(" app-server"))
|
|
49
64
|
return "app-server";
|
|
@@ -59,6 +74,9 @@ function inferKind(cmd) {
|
|
|
59
74
|
return "opencode-cli";
|
|
60
75
|
return "opencode-tui";
|
|
61
76
|
}
|
|
77
|
+
const claudeInfo = summarizeClaudeCommand(cmd);
|
|
78
|
+
if (claudeInfo)
|
|
79
|
+
return claudeInfo.kind;
|
|
62
80
|
return "unknown";
|
|
63
81
|
}
|
|
64
82
|
function shortenCmd(cmd, max = 120) {
|
|
@@ -69,6 +87,9 @@ function shortenCmd(cmd, max = 120) {
|
|
|
69
87
|
}
|
|
70
88
|
function parseDoingFromCmd(cmd) {
|
|
71
89
|
const parts = cmd.split(/\s+/g);
|
|
90
|
+
const claudeInfo = summarizeClaudeCommand(cmd);
|
|
91
|
+
if (claudeInfo?.doing)
|
|
92
|
+
return claudeInfo.doing;
|
|
72
93
|
const openIndex = parts.findIndex((part) => part === "opencode" || part.endsWith("/opencode"));
|
|
73
94
|
if (openIndex !== -1) {
|
|
74
95
|
const mode = parts[openIndex + 1];
|
|
@@ -182,7 +203,11 @@ function deriveTitle(doing, repo, pid, kind) {
|
|
|
182
203
|
}
|
|
183
204
|
if (repo)
|
|
184
205
|
return repo;
|
|
185
|
-
const prefix = kind.startsWith("opencode")
|
|
206
|
+
const prefix = kind.startsWith("opencode")
|
|
207
|
+
? "opencode"
|
|
208
|
+
: kind.startsWith("claude")
|
|
209
|
+
? "claude"
|
|
210
|
+
: "codex";
|
|
186
211
|
return `${prefix}#${pid}`;
|
|
187
212
|
}
|
|
188
213
|
function sanitizeSummary(summary) {
|
|
@@ -316,7 +341,11 @@ export async function scanCodexProcesses() {
|
|
|
316
341
|
const opencodeProcs = processes
|
|
317
342
|
.filter((proc) => isOpenCodeProcess(proc.cmd, proc.name))
|
|
318
343
|
.filter((proc) => !codexPidSet.has(proc.pid));
|
|
319
|
-
const
|
|
344
|
+
const opencodePidSet = new Set(opencodeProcs.map((proc) => proc.pid));
|
|
345
|
+
const claudeProcs = processes
|
|
346
|
+
.filter((proc) => isClaudeProcess(proc.cmd, proc.name))
|
|
347
|
+
.filter((proc) => !codexPidSet.has(proc.pid) && !opencodePidSet.has(proc.pid));
|
|
348
|
+
const pids = Array.from(new Set([...codexProcs, ...opencodeProcs, ...claudeProcs].map((proc) => proc.pid)));
|
|
320
349
|
let usage = {};
|
|
321
350
|
try {
|
|
322
351
|
usage = (await pidusage(pids));
|
|
@@ -332,6 +361,7 @@ export async function scanCodexProcesses() {
|
|
|
332
361
|
const opencodePort = Number(process.env.CONSENSUS_OPENCODE_PORT || 4096);
|
|
333
362
|
const opencodeResult = await getOpenCodeSessions(opencodeHost, opencodePort, {
|
|
334
363
|
silent: true,
|
|
364
|
+
timeoutMs: Number(process.env.CONSENSUS_OPENCODE_TIMEOUT_MS || 1000),
|
|
335
365
|
});
|
|
336
366
|
await ensureOpenCodeServer(opencodeHost, opencodePort, opencodeResult);
|
|
337
367
|
if (opencodeProcs.length) {
|
|
@@ -441,6 +471,9 @@ export async function scanCodexProcesses() {
|
|
|
441
471
|
events,
|
|
442
472
|
});
|
|
443
473
|
}
|
|
474
|
+
const opencodeEventWindowMs = Number(process.env.CONSENSUS_OPENCODE_EVENT_ACTIVE_MS || 90000);
|
|
475
|
+
const opencodeHoldMs = Number(process.env.CONSENSUS_OPENCODE_ACTIVE_HOLD_MS || 120000);
|
|
476
|
+
const cpuThreshold = Number(process.env.CONSENSUS_CPU_ACTIVE || 1);
|
|
444
477
|
for (const proc of opencodeProcs) {
|
|
445
478
|
const stats = usage[proc.pid] || {};
|
|
446
479
|
const cpu = typeof stats.cpu === "number" ? stats.cpu : 0;
|
|
@@ -464,7 +497,7 @@ export async function scanCodexProcesses() {
|
|
|
464
497
|
const cwd = redactText(cwdRaw) || cwdRaw;
|
|
465
498
|
const repoRoot = cwdRaw ? findRepoRoot(cwdRaw) : null;
|
|
466
499
|
const repoName = repoRoot ? path.basename(repoRoot) : undefined;
|
|
467
|
-
|
|
500
|
+
const lastActivityAt = parseTimestamp(session?.lastActivity ||
|
|
468
501
|
session?.lastActivityAt ||
|
|
469
502
|
storageSession?.time?.updated ||
|
|
470
503
|
storageSession?.time?.created ||
|
|
@@ -477,15 +510,15 @@ export async function scanCodexProcesses() {
|
|
|
477
510
|
const statusRaw = typeof session?.status === "string" ? session.status : undefined;
|
|
478
511
|
const status = statusRaw?.toLowerCase();
|
|
479
512
|
const statusIsError = !!status && /error|failed|failure/.test(status);
|
|
480
|
-
const statusIsActive = !!status && /running|active|processing/.test(status);
|
|
481
513
|
const statusIsIdle = !!status && /idle|stopped|paused/.test(status);
|
|
482
514
|
let hasError = statusIsError;
|
|
483
|
-
let inFlight = statusIsActive;
|
|
484
515
|
const model = typeof session?.model === "string" ? session.model : undefined;
|
|
485
516
|
let doing = sessionTitle;
|
|
486
517
|
let summary;
|
|
487
518
|
let events;
|
|
488
519
|
const eventActivity = getOpenCodeActivityBySession(sessionId) || getOpenCodeActivityByPid(proc.pid);
|
|
520
|
+
let lastEventAt = eventActivity?.lastEventAt;
|
|
521
|
+
let inFlight = eventActivity?.inFlight;
|
|
489
522
|
if (eventActivity) {
|
|
490
523
|
events = eventActivity.events;
|
|
491
524
|
summary = eventActivity.summary || summary;
|
|
@@ -497,6 +530,9 @@ export async function scanCodexProcesses() {
|
|
|
497
530
|
if (eventActivity.summary?.current)
|
|
498
531
|
doing = eventActivity.summary.current;
|
|
499
532
|
}
|
|
533
|
+
if (!lastEventAt && statusIsIdle) {
|
|
534
|
+
lastEventAt = undefined;
|
|
535
|
+
}
|
|
500
536
|
if (!doing) {
|
|
501
537
|
doing =
|
|
502
538
|
parseDoingFromCmd(proc.cmd || "") || shortenCmd(proc.cmd || proc.name || "");
|
|
@@ -505,37 +541,34 @@ export async function scanCodexProcesses() {
|
|
|
505
541
|
summary = { current: doing };
|
|
506
542
|
const id = `${proc.pid}`;
|
|
507
543
|
const cached = activityCache.get(id);
|
|
508
|
-
const
|
|
544
|
+
const kind = inferKind(cmdRaw);
|
|
545
|
+
const activity = deriveOpenCodeState({
|
|
509
546
|
cpu,
|
|
510
547
|
hasError,
|
|
511
|
-
lastEventAt,
|
|
548
|
+
lastEventAt: lastEventAt,
|
|
512
549
|
inFlight,
|
|
550
|
+
status,
|
|
551
|
+
isServer: kind === "opencode-server",
|
|
513
552
|
previousActiveAt: cached?.lastActiveAt,
|
|
514
553
|
now,
|
|
554
|
+
cpuThreshold,
|
|
555
|
+
eventWindowMs: opencodeEventWindowMs,
|
|
556
|
+
holdMs: opencodeHoldMs,
|
|
515
557
|
});
|
|
516
558
|
let state = activity.state;
|
|
517
|
-
|
|
518
|
-
state = "error";
|
|
519
|
-
else if (statusIsActive)
|
|
520
|
-
state = "active";
|
|
521
|
-
else if (statusIsIdle)
|
|
522
|
-
state = "idle";
|
|
523
|
-
const hasSignal = statusIsActive ||
|
|
524
|
-
statusIsIdle ||
|
|
559
|
+
const hasSignal = statusIsIdle ||
|
|
525
560
|
statusIsError ||
|
|
526
561
|
typeof lastEventAt === "number" ||
|
|
527
|
-
|
|
562
|
+
typeof inFlight === "boolean";
|
|
528
563
|
if (!opencodeApiAvailable && !hasSignal)
|
|
529
564
|
state = "idle";
|
|
530
|
-
const cpuThreshold = Number(process.env.CONSENSUS_CPU_ACTIVE || 1);
|
|
531
565
|
if (!hasSignal && cpu <= cpuThreshold) {
|
|
532
566
|
state = "idle";
|
|
533
567
|
}
|
|
534
|
-
activityCache.set(id, { lastActiveAt:
|
|
568
|
+
activityCache.set(id, { lastActiveAt: activity.lastActiveAt, lastSeenAt: now });
|
|
535
569
|
seenIds.add(id);
|
|
536
570
|
const cmd = redactText(cmdRaw) || cmdRaw;
|
|
537
571
|
const cmdShort = shortenCmd(cmd);
|
|
538
|
-
const kind = inferKind(cmd);
|
|
539
572
|
const startedAt = startMs ? Math.floor(startMs / 1000) : undefined;
|
|
540
573
|
const computedTitle = sessionTitle || deriveTitle(doing, repoName, proc.pid, kind);
|
|
541
574
|
const safeSummary = sanitizeSummary(summary);
|
|
@@ -559,6 +592,60 @@ export async function scanCodexProcesses() {
|
|
|
559
592
|
events,
|
|
560
593
|
});
|
|
561
594
|
}
|
|
595
|
+
for (const proc of claudeProcs) {
|
|
596
|
+
const stats = usage[proc.pid] || {};
|
|
597
|
+
const cpu = typeof stats.cpu === "number" ? stats.cpu : 0;
|
|
598
|
+
const mem = typeof stats.memory === "number" ? stats.memory : 0;
|
|
599
|
+
const elapsed = stats.elapsed;
|
|
600
|
+
const startMs = typeof elapsed === "number"
|
|
601
|
+
? Date.now() - elapsed
|
|
602
|
+
: startTimes.get(proc.pid);
|
|
603
|
+
const cmdRaw = proc.cmd || proc.name || "";
|
|
604
|
+
const claudeInfo = summarizeClaudeCommand(cmdRaw);
|
|
605
|
+
const doing = claudeInfo?.doing ||
|
|
606
|
+
parseDoingFromCmd(cmdRaw) ||
|
|
607
|
+
shortenCmd(cmdRaw || proc.name || "");
|
|
608
|
+
const summary = doing ? { current: doing } : undefined;
|
|
609
|
+
const model = claudeInfo?.model;
|
|
610
|
+
const cwdRaw = cwds.get(proc.pid);
|
|
611
|
+
const cwd = redactText(cwdRaw) || cwdRaw;
|
|
612
|
+
const repoRoot = cwdRaw ? findRepoRoot(cwdRaw) : null;
|
|
613
|
+
const repoName = repoRoot ? path.basename(repoRoot) : undefined;
|
|
614
|
+
const kind = claudeInfo?.kind || inferKind(cmdRaw);
|
|
615
|
+
const id = `${proc.pid}`;
|
|
616
|
+
const cached = activityCache.get(id);
|
|
617
|
+
const activity = deriveClaudeState({
|
|
618
|
+
cpu,
|
|
619
|
+
info: claudeInfo,
|
|
620
|
+
previousActiveAt: cached?.lastActiveAt,
|
|
621
|
+
now,
|
|
622
|
+
});
|
|
623
|
+
const state = activity.state;
|
|
624
|
+
activityCache.set(id, { lastActiveAt: state === "active" ? activity.lastActiveAt : undefined, lastSeenAt: now });
|
|
625
|
+
seenIds.add(id);
|
|
626
|
+
const cmd = redactText(cmdRaw) || cmdRaw;
|
|
627
|
+
const cmdShort = shortenCmd(cmd);
|
|
628
|
+
const startedAt = startMs ? Math.floor(startMs / 1000) : undefined;
|
|
629
|
+
const computedTitle = deriveTitle(doing, repoName, proc.pid, kind);
|
|
630
|
+
const safeSummary = sanitizeSummary(summary);
|
|
631
|
+
agents.push({
|
|
632
|
+
id,
|
|
633
|
+
pid: proc.pid,
|
|
634
|
+
startedAt,
|
|
635
|
+
title: redactText(computedTitle) || computedTitle,
|
|
636
|
+
cmd,
|
|
637
|
+
cmdShort,
|
|
638
|
+
kind,
|
|
639
|
+
cpu,
|
|
640
|
+
mem,
|
|
641
|
+
state,
|
|
642
|
+
doing: redactText(doing) || doing,
|
|
643
|
+
repo: repoName,
|
|
644
|
+
cwd,
|
|
645
|
+
model,
|
|
646
|
+
summary: safeSummary,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
562
649
|
for (const id of activityCache.keys()) {
|
|
563
650
|
if (!seenIds.has(id)) {
|
|
564
651
|
activityCache.delete(id);
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -21,15 +21,55 @@ const gridScale = 2;
|
|
|
21
21
|
const query = new URLSearchParams(window.location.search);
|
|
22
22
|
const mockMode = query.get("mock") === "1";
|
|
23
23
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
const cliPalette = {
|
|
25
|
+
codex: {
|
|
26
|
+
agent: {
|
|
27
|
+
active: { top: "#3d8f7f", left: "#2d6d61", right: "#275b52", stroke: "#54cdb1" },
|
|
28
|
+
idle: { top: "#384a57", left: "#2b3943", right: "#25323b", stroke: "#4f6b7a" },
|
|
29
|
+
error: { top: "#82443c", left: "#6d3530", right: "#5a2c28", stroke: "#d1584b" },
|
|
30
|
+
},
|
|
31
|
+
server: {
|
|
32
|
+
active: { top: "#4e665e", left: "#3d524b", right: "#32453f", stroke: "#79b8a8" },
|
|
33
|
+
idle: { top: "#353f48", left: "#2a323a", right: "#232a30", stroke: "#526577" },
|
|
34
|
+
error: { top: "#82443c", left: "#6d3530", right: "#5a2c28", stroke: "#d1584b" },
|
|
35
|
+
},
|
|
36
|
+
accent: "#57f2c6",
|
|
37
|
+
accentStrong: "rgba(87, 242, 198, 0.6)",
|
|
38
|
+
accentSoft: "rgba(87, 242, 198, 0.35)",
|
|
39
|
+
glow: "87, 242, 198",
|
|
40
|
+
},
|
|
41
|
+
opencode: {
|
|
42
|
+
agent: {
|
|
43
|
+
active: { top: "#8a6a2f", left: "#6f5626", right: "#5b4621", stroke: "#f1bd4f" },
|
|
44
|
+
idle: { top: "#3c3a37", left: "#2f2d2a", right: "#262322", stroke: "#7f6f56" },
|
|
45
|
+
error: { top: "#86443b", left: "#70352f", right: "#5c2c28", stroke: "#e0705c" },
|
|
46
|
+
},
|
|
47
|
+
server: {
|
|
48
|
+
active: { top: "#7d6a2b", left: "#665725", right: "#54481f", stroke: "#f5c453" },
|
|
49
|
+
idle: { top: "#353b42", left: "#272c33", right: "#1f242a", stroke: "#6b7380" },
|
|
50
|
+
error: { top: "#86443b", left: "#70352f", right: "#5c2c28", stroke: "#e0705c" },
|
|
51
|
+
},
|
|
52
|
+
accent: "#f5c453",
|
|
53
|
+
accentStrong: "rgba(245, 196, 83, 0.6)",
|
|
54
|
+
accentSoft: "rgba(245, 196, 83, 0.35)",
|
|
55
|
+
glow: "245, 196, 83",
|
|
56
|
+
},
|
|
57
|
+
claude: {
|
|
58
|
+
agent: {
|
|
59
|
+
active: { top: "#3f6fa3", left: "#2f5580", right: "#25476a", stroke: "#7fb7ff" },
|
|
60
|
+
idle: { top: "#374252", left: "#2a323f", right: "#232a35", stroke: "#5c6f85" },
|
|
61
|
+
error: { top: "#7f4140", left: "#683334", right: "#552a2b", stroke: "#e06b6a" },
|
|
62
|
+
},
|
|
63
|
+
server: {
|
|
64
|
+
active: { top: "#4b5f74", left: "#3a4a5c", right: "#2f3d4d", stroke: "#91b4d6" },
|
|
65
|
+
idle: { top: "#323b47", left: "#262d36", right: "#20262d", stroke: "#556577" },
|
|
66
|
+
error: { top: "#7f4140", left: "#683334", right: "#552a2b", stroke: "#e06b6a" },
|
|
67
|
+
},
|
|
68
|
+
accent: "#7fb7ff",
|
|
69
|
+
accentStrong: "rgba(127, 183, 255, 0.6)",
|
|
70
|
+
accentSoft: "rgba(127, 183, 255, 0.35)",
|
|
71
|
+
glow: "127, 183, 255",
|
|
72
|
+
},
|
|
33
73
|
};
|
|
34
74
|
const stateOpacity = {
|
|
35
75
|
active: 1,
|
|
@@ -222,19 +262,38 @@ function isServerKind(kind) {
|
|
|
222
262
|
return kind === "app-server" || kind === "opencode-server";
|
|
223
263
|
}
|
|
224
264
|
|
|
265
|
+
function cliForAgent(agent) {
|
|
266
|
+
const kind = agent.kind || "";
|
|
267
|
+
if (kind.startsWith("opencode")) return "opencode";
|
|
268
|
+
if (kind.startsWith("claude")) return "claude";
|
|
269
|
+
return "codex";
|
|
270
|
+
}
|
|
271
|
+
|
|
225
272
|
function paletteFor(agent) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return
|
|
273
|
+
const cli = cliForAgent(agent);
|
|
274
|
+
const palette = cliPalette[cli] || cliPalette.codex;
|
|
275
|
+
const scope = isServerKind(agent.kind) ? palette.server : palette.agent;
|
|
276
|
+
return scope[agent.state] || scope.idle;
|
|
230
277
|
}
|
|
231
278
|
|
|
232
279
|
function accentFor(agent) {
|
|
233
|
-
|
|
280
|
+
const cli = cliForAgent(agent);
|
|
281
|
+
return (cliPalette[cli] || cliPalette.codex).accent;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function accentStrongFor(agent) {
|
|
285
|
+
const cli = cliForAgent(agent);
|
|
286
|
+
return (cliPalette[cli] || cliPalette.codex).accentStrong;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function accentSoftFor(agent) {
|
|
290
|
+
const cli = cliForAgent(agent);
|
|
291
|
+
return (cliPalette[cli] || cliPalette.codex).accentSoft;
|
|
234
292
|
}
|
|
235
293
|
|
|
236
294
|
function accentGlow(agent, alpha) {
|
|
237
|
-
const
|
|
295
|
+
const cli = cliForAgent(agent);
|
|
296
|
+
const tint = (cliPalette[cli] || cliPalette.codex).glow;
|
|
238
297
|
return `rgba(${tint}, ${alpha})`;
|
|
239
298
|
}
|
|
240
299
|
|
|
@@ -324,9 +383,12 @@ function renderLaneList(items, container, emptyLabel) {
|
|
|
324
383
|
const doingRaw = agent.summary?.current || agent.doing || agent.cmdShort || "";
|
|
325
384
|
const doing = escapeHtml(truncate(doingRaw, 80));
|
|
326
385
|
const selectedClass = selected && selected.id === agent.id ? "is-selected" : "";
|
|
386
|
+
const accent = accentFor(agent);
|
|
387
|
+
const accentGlow = accentSoftFor(agent);
|
|
388
|
+
const cli = cliForAgent(agent);
|
|
327
389
|
const label = escapeHtml(labelFor(agent));
|
|
328
390
|
return `
|
|
329
|
-
<button class="lane-item ${selectedClass}" type="button" data-id="${agent.id}">
|
|
391
|
+
<button class="lane-item ${selectedClass} cli-${cli}" type="button" data-id="${agent.id}" style="--cli-accent: ${accent}; --cli-accent-glow: ${accentGlow};">
|
|
330
392
|
<div class="lane-pill ${agent.state}"></div>
|
|
331
393
|
<div class="lane-copy">
|
|
332
394
|
<div class="lane-label">${label}</div>
|
|
@@ -490,8 +552,8 @@ function draw() {
|
|
|
490
552
|
const isActive = item.agent.state === "active";
|
|
491
553
|
const isServer = isServerKind(item.agent.kind);
|
|
492
554
|
const accent = accentFor(item.agent);
|
|
493
|
-
const accentStrong =
|
|
494
|
-
const accentSoft =
|
|
555
|
+
const accentStrong = accentStrongFor(item.agent);
|
|
556
|
+
const accentSoft = accentSoftFor(item.agent);
|
|
495
557
|
const pulse =
|
|
496
558
|
isActive && !reducedMotion
|
|
497
559
|
? 4 + Math.sin(time / 200) * 3
|
package/public/style.css
CHANGED
|
@@ -295,12 +295,15 @@ body {
|
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
.lane-item {
|
|
298
|
+
--cli-accent: rgba(87, 242, 198, 0.7);
|
|
299
|
+
--cli-accent-glow: rgba(87, 242, 198, 0.25);
|
|
298
300
|
display: flex;
|
|
299
301
|
align-items: flex-start;
|
|
300
302
|
gap: 10px;
|
|
301
303
|
padding: 10px;
|
|
302
304
|
border-radius: 10px;
|
|
303
305
|
border: 1px solid rgba(62, 78, 89, 0.5);
|
|
306
|
+
border-left: 3px solid var(--cli-accent);
|
|
304
307
|
background: rgba(13, 17, 22, 0.85);
|
|
305
308
|
cursor: pointer;
|
|
306
309
|
text-align: left;
|
|
@@ -312,16 +315,18 @@ body {
|
|
|
312
315
|
}
|
|
313
316
|
|
|
314
317
|
.lane-item:hover {
|
|
315
|
-
border-color:
|
|
318
|
+
border-color: var(--cli-accent);
|
|
319
|
+
border-left-color: var(--cli-accent);
|
|
316
320
|
}
|
|
317
321
|
|
|
318
322
|
.lane-item.is-selected {
|
|
319
|
-
border-color:
|
|
320
|
-
|
|
323
|
+
border-color: var(--cli-accent);
|
|
324
|
+
border-left-color: var(--cli-accent);
|
|
325
|
+
box-shadow: 0 0 12px var(--cli-accent-glow);
|
|
321
326
|
}
|
|
322
327
|
|
|
323
328
|
.lane-item:focus-visible {
|
|
324
|
-
outline: 2px solid
|
|
329
|
+
outline: 2px solid var(--cli-accent);
|
|
325
330
|
outline-offset: 2px;
|
|
326
331
|
}
|
|
327
332
|
|