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 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
- Live isometric atlas for Codex CLI processes, rendered in a local browser.
3
+ [![npm](https://img.shields.io/npm/v/consensus-cli.svg?color=0f766e)](https://www.npmjs.com/package/consensus-cli)
4
+ [![GitHub release](https://img.shields.io/github/v/release/integrate-your-mind/consensus-cli?display_name=tag&color=2563eb)](https://github.com/integrate-your-mind/consensus-cli/releases)
5
+ [![License](https://img.shields.io/npm/l/consensus-cli.svg?color=6b7280)](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 that highlights what is working right now.
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 recent session JSONL under `CODEX_HOME/sessions/`.
51
- 3) Poll and push snapshots over WebSocket.
52
- 4) Render tiles on a canvas with isometric projection.
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
- for (const line of lines) {
318
+ const processLine = (line) => {
317
319
  if (!line.trim())
318
- continue;
320
+ return false;
319
321
  let ev;
320
322
  try {
321
323
  ev = JSON.parse(line);
322
324
  }
323
325
  catch {
324
- continue;
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
- else {
362
- state.lastEventAt = Math.max(state.lastEventAt || 0, ts);
363
- if (isError) {
364
- state.lastError = {
365
- ts,
366
- type: typeof type === "string" ? type : "event",
367
- summary: "error",
368
- isError,
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
  }
@@ -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
- if (!summary)
213
- return;
214
- const entry = {
215
- ts,
216
- type: typeof type === "string" ? type : "event",
217
- summary,
218
- isError,
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
- recordEvent(state, entry, kind);
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
- recordEvent(state, entry, kind);
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") ? "opencode" : "codex";
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 pids = Array.from(new Set([...codexProcs, ...opencodeProcs].map((proc) => proc.pid)));
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
- let lastEventAt = parseTimestamp(session?.lastActivity ||
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 activity = deriveStateWithHold({
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
- 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 ||
559
+ const hasSignal = statusIsIdle ||
525
560
  statusIsError ||
526
561
  typeof lastEventAt === "number" ||
527
- !!inFlight;
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: state === "active" ? activity.lastActiveAt : undefined, lastSeenAt: now });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "consensus-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
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 statePalette = {
25
- active: { top: "#3d8f7f", left: "#2d6d61", right: "#275b52", stroke: "#54cdb1" },
26
- idle: { top: "#384a57", left: "#2b3943", right: "#25323b", stroke: "#4f6b7a" },
27
- error: { top: "#82443c", left: "#6d3530", right: "#5a2c28", stroke: "#d1584b" },
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,
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
- if (isServerKind(agent.kind)) {
227
- return serverPalette[agent.state] || serverPalette.idle;
228
- }
229
- return statePalette[agent.state] || statePalette.idle;
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
- return isServerKind(agent.kind) ? "#f5c453" : "#57f2c6";
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 tint = isServerKind(agent.kind) ? "245, 196, 83" : "87, 242, 198";
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 = 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)";
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: rgba(87, 242, 198, 0.5);
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: rgba(87, 242, 198, 0.7);
320
- box-shadow: 0 0 12px rgba(87, 242, 198, 0.25);
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 rgba(87, 242, 198, 0.6);
329
+ outline: 2px solid var(--cli-accent);
325
330
  outline-offset: 2px;
326
331
  }
327
332