consensus-cli 0.1.2 → 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 +16 -0
- package/README.md +22 -10
- package/dist/claudeCli.js +125 -0
- package/dist/cli.js +12 -0
- package/dist/codexLogs.js +34 -13
- package/dist/opencodeApi.js +84 -0
- package/dist/opencodeEvents.js +388 -0
- package/dist/opencodeServer.js +91 -0
- package/dist/opencodeState.js +36 -0
- package/dist/opencodeStorage.js +127 -0
- package/dist/scan.js +341 -5
- package/package.json +1 -1
- package/public/app.js +146 -27
- package/public/index.html +3 -0
- package/public/style.css +21 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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
|
+
|
|
14
|
+
## 0.1.4 - 2026-01-24
|
|
15
|
+
- Fix OpenCode event tracking build error (pid activity typing).
|
|
16
|
+
|
|
17
|
+
## 0.1.3 - 2026-01-24
|
|
18
|
+
- Add OpenCode integration (API sessions, event stream, storage fallback).
|
|
19
|
+
- Autostart OpenCode server with opt-out and CLI flags.
|
|
20
|
+
- Split servers into a dedicated lane with distinct palette.
|
|
21
|
+
- Improve layout keys to prevent tile overlap.
|
|
22
|
+
- Add OpenCode unit/integration tests and configuration docs.
|
|
23
|
+
|
|
8
24
|
## 0.1.2 - 2026-01-24
|
|
9
25
|
- Lower CPU threshold for active detection.
|
|
10
26
|
- Increase activity window defaults for long-running turns.
|
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`
|
|
@@ -62,6 +70,10 @@ consensus dev server running on http://127.0.0.1:8787
|
|
|
62
70
|
- `CONSENSUS_PORT`: server port (default `8787`).
|
|
63
71
|
- `CONSENSUS_POLL_MS`: polling interval in ms (default `1000`).
|
|
64
72
|
- `CONSENSUS_CODEX_HOME`: override Codex home (default `~/.codex`).
|
|
73
|
+
- `CONSENSUS_OPENCODE_HOST`: OpenCode server host (default `127.0.0.1`).
|
|
74
|
+
- `CONSENSUS_OPENCODE_PORT`: OpenCode server port (default `4096`).
|
|
75
|
+
- `CONSENSUS_OPENCODE_AUTOSTART`: set to `0` to disable OpenCode autostart.
|
|
76
|
+
- `CONSENSUS_OPENCODE_EVENTS`: set to `0` to disable OpenCode event stream.
|
|
65
77
|
- `CONSENSUS_PROCESS_MATCH`: regex to match codex processes.
|
|
66
78
|
- `CONSENSUS_REDACT_PII`: set to `0` to disable redaction (default enabled).
|
|
67
79
|
- `CONSENSUS_EVENT_ACTIVE_MS`: active window after last event in ms (default `300000`).
|
|
@@ -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/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)
|
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
|
}
|
|
@@ -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
|
+
}
|