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
|
@@ -0,0 +1,388 @@
|
|
|
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, inFlight };
|
|
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, inFlight };
|
|
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, inFlight };
|
|
153
|
+
}
|
|
154
|
+
if (type && type !== "event") {
|
|
155
|
+
const summary = redactText(`event: ${type}`) || `event: ${type}`;
|
|
156
|
+
return { summary, kind: "other", isError, type, inFlight };
|
|
157
|
+
}
|
|
158
|
+
return { kind: "other", isError, type, inFlight };
|
|
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
|
+
const entry = summary
|
|
213
|
+
? {
|
|
214
|
+
ts,
|
|
215
|
+
type: typeof type === "string" ? type : "event",
|
|
216
|
+
summary,
|
|
217
|
+
isError,
|
|
218
|
+
}
|
|
219
|
+
: null;
|
|
220
|
+
const now = nowMs();
|
|
221
|
+
if (sessionId) {
|
|
222
|
+
const state = ensureActivity(sessionId, sessionActivity, now);
|
|
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
|
+
}
|
|
237
|
+
if (typeof inFlight === "boolean")
|
|
238
|
+
state.inFlight = inFlight;
|
|
239
|
+
}
|
|
240
|
+
if (typeof pid === "number") {
|
|
241
|
+
const state = ensureActivity(pid, pidActivity, now);
|
|
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
|
+
}
|
|
256
|
+
if (typeof inFlight === "boolean")
|
|
257
|
+
state.inFlight = inFlight;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
export function ingestOpenCodeEvent(raw) {
|
|
261
|
+
handleRawEvent(raw);
|
|
262
|
+
}
|
|
263
|
+
function pruneStale() {
|
|
264
|
+
const cutoff = nowMs() - STALE_TTL_MS;
|
|
265
|
+
for (const [key, state] of sessionActivity.entries()) {
|
|
266
|
+
if (state.lastSeenAt < cutoff)
|
|
267
|
+
sessionActivity.delete(key);
|
|
268
|
+
}
|
|
269
|
+
for (const [key, state] of pidActivity.entries()) {
|
|
270
|
+
if (state.lastSeenAt < cutoff)
|
|
271
|
+
pidActivity.delete(key);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async function connectStream(host, port) {
|
|
275
|
+
connecting = true;
|
|
276
|
+
lastConnectAt = nowMs();
|
|
277
|
+
try {
|
|
278
|
+
const response = await fetch(`http://${host}:${port}/global/event`, {
|
|
279
|
+
headers: {
|
|
280
|
+
Accept: "text/event-stream",
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
if (!response.ok || !response.body) {
|
|
284
|
+
connected = false;
|
|
285
|
+
connecting = false;
|
|
286
|
+
lastFailureAt = nowMs();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
connected = true;
|
|
290
|
+
connecting = false;
|
|
291
|
+
const reader = response.body.getReader();
|
|
292
|
+
let buffer = "";
|
|
293
|
+
let currentEvent;
|
|
294
|
+
let dataLines = [];
|
|
295
|
+
while (true) {
|
|
296
|
+
const { value, done } = await reader.read();
|
|
297
|
+
if (done)
|
|
298
|
+
break;
|
|
299
|
+
buffer += Buffer.from(value).toString("utf8");
|
|
300
|
+
let idx;
|
|
301
|
+
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
302
|
+
const line = buffer.slice(0, idx).trimEnd();
|
|
303
|
+
buffer = buffer.slice(idx + 1);
|
|
304
|
+
if (!line) {
|
|
305
|
+
if (dataLines.length) {
|
|
306
|
+
const payload = dataLines.join("\n");
|
|
307
|
+
try {
|
|
308
|
+
const parsed = JSON.parse(payload);
|
|
309
|
+
const raw = parsed?.payload ?? parsed;
|
|
310
|
+
if (currentEvent && typeof raw === "object" && !raw.type) {
|
|
311
|
+
raw.type = currentEvent;
|
|
312
|
+
}
|
|
313
|
+
if (parsed?.type && typeof raw === "object" && !raw.type) {
|
|
314
|
+
raw.type = parsed.type;
|
|
315
|
+
}
|
|
316
|
+
handleRawEvent(raw);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// ignore malformed payloads
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
currentEvent = undefined;
|
|
323
|
+
dataLines = [];
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (line.startsWith("event:")) {
|
|
327
|
+
currentEvent = line.slice(6).trim();
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (line.startsWith("data:")) {
|
|
331
|
+
dataLines.push(line.slice(5).trim());
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
lastFailureAt = nowMs();
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
connected = false;
|
|
341
|
+
connecting = false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
export function ensureOpenCodeEventStream(host, port) {
|
|
345
|
+
if (process.env.CONSENSUS_OPENCODE_EVENTS === "0")
|
|
346
|
+
return;
|
|
347
|
+
const now = nowMs();
|
|
348
|
+
if (connecting || connected)
|
|
349
|
+
return;
|
|
350
|
+
if (now - lastConnectAt < RECONNECT_MIN_MS)
|
|
351
|
+
return;
|
|
352
|
+
if (now - lastFailureAt < RECONNECT_MIN_MS)
|
|
353
|
+
return;
|
|
354
|
+
pruneStale();
|
|
355
|
+
void connectStream(host, port);
|
|
356
|
+
}
|
|
357
|
+
export function getOpenCodeActivityBySession(sessionId) {
|
|
358
|
+
if (!sessionId)
|
|
359
|
+
return null;
|
|
360
|
+
const state = sessionActivity.get(sessionId);
|
|
361
|
+
if (!state)
|
|
362
|
+
return null;
|
|
363
|
+
const events = state.events.slice(-20);
|
|
364
|
+
const hasError = !!state.lastError || events.some((ev) => ev.isError);
|
|
365
|
+
return {
|
|
366
|
+
events,
|
|
367
|
+
summary: state.summary,
|
|
368
|
+
lastEventAt: state.lastEventAt,
|
|
369
|
+
hasError,
|
|
370
|
+
inFlight: state.inFlight,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
export function getOpenCodeActivityByPid(pid) {
|
|
374
|
+
if (typeof pid !== "number")
|
|
375
|
+
return null;
|
|
376
|
+
const state = pidActivity.get(pid);
|
|
377
|
+
if (!state)
|
|
378
|
+
return null;
|
|
379
|
+
const events = state.events.slice(-20);
|
|
380
|
+
const hasError = !!state.lastError || events.some((ev) => ev.isError);
|
|
381
|
+
return {
|
|
382
|
+
events,
|
|
383
|
+
summary: state.summary,
|
|
384
|
+
lastEventAt: state.lastEventAt,
|
|
385
|
+
hasError,
|
|
386
|
+
inFlight: state.inFlight,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
@@ -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,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
|
+
}
|
|
@@ -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
|
+
}
|