opencode-dashboard 0.1.0 → 0.2.0
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/README.md +20 -1
- package/bin/cli.ts +16 -2
- package/dist/assets/{index-W-qyIr7d.js → index-DJanV0JQ.js} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugin/index.ts +245 -5
- package/server/index.ts +23 -3
- package/server/pid.ts +115 -5
- package/server/routes.ts +131 -0
- package/server/sse.ts +16 -0
- package/server/state.ts +518 -1
- package/shared/types.ts +17 -7
- package/shared/version.ts +46 -0
package/dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>OpenCode Dashboard</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DJanV0JQ.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-mMdK5PVd.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body class="min-h-screen bg-background text-foreground antialiased">
|
package/package.json
CHANGED
package/plugin/index.ts
CHANGED
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
21
21
|
import { tool } from "@opencode-ai/plugin";
|
|
22
22
|
import type { BeadRecord, BeadDiff, ColumnConfig } from "../shared/types";
|
|
23
|
-
import {
|
|
23
|
+
import { computeBuildHash } from "../shared/version";
|
|
24
|
+
import { isServerRunning, readPid, writePid, removePid, openLogFile, getLogFilePath, readAutostart, writeAutostart, removeAutostart } from "../server/pid";
|
|
24
25
|
import { join, resolve } from "path";
|
|
25
26
|
import { readdir, readFile, copyFile, mkdir, stat } from "fs/promises";
|
|
27
|
+
import { closeSync } from "fs";
|
|
26
28
|
|
|
27
29
|
// ─── Constants ─────────────────────────────────────────────────
|
|
28
30
|
|
|
@@ -74,6 +76,7 @@ let currentBeadId: string | null = null;
|
|
|
74
76
|
let sessionToAgent = new Map<string, string>();
|
|
75
77
|
let pendingAgentType: string | null = null;
|
|
76
78
|
let currentAgentName: string | null = null; // tracks the agent handling the current chat.message
|
|
79
|
+
let activePrimarySession: string | null = null; // tracks primary session with active agent:active event
|
|
77
80
|
|
|
78
81
|
// Agent discovery results
|
|
79
82
|
let discoveredAgents: DiscoveredAgent[] = [];
|
|
@@ -432,6 +435,7 @@ function generateColumnConfig(agents: DiscoveredAgent[]): ColumnConfig[] {
|
|
|
432
435
|
type: "agent",
|
|
433
436
|
color: nextColor(orchestratorAgent),
|
|
434
437
|
order: order++,
|
|
438
|
+
group: "pipeline",
|
|
435
439
|
});
|
|
436
440
|
}
|
|
437
441
|
|
|
@@ -443,6 +447,7 @@ function generateColumnConfig(agents: DiscoveredAgent[]): ColumnConfig[] {
|
|
|
443
447
|
type: "agent",
|
|
444
448
|
color: nextColor(agent),
|
|
445
449
|
order: order++,
|
|
450
|
+
group: "pipeline",
|
|
446
451
|
});
|
|
447
452
|
}
|
|
448
453
|
|
|
@@ -454,6 +459,7 @@ function generateColumnConfig(agents: DiscoveredAgent[]): ColumnConfig[] {
|
|
|
454
459
|
type: "agent",
|
|
455
460
|
color: nextColor(agent),
|
|
456
461
|
order: order++,
|
|
462
|
+
group: "standalone",
|
|
457
463
|
});
|
|
458
464
|
}
|
|
459
465
|
|
|
@@ -543,6 +549,26 @@ async function checkServerHealth(): Promise<boolean> {
|
|
|
543
549
|
}
|
|
544
550
|
}
|
|
545
551
|
|
|
552
|
+
/**
|
|
553
|
+
* Get the server's build hash from the health endpoint.
|
|
554
|
+
* Returns null if the server doesn't report a hash or is unreachable.
|
|
555
|
+
*/
|
|
556
|
+
async function getServerBuildHash(port?: number): Promise<string | null> {
|
|
557
|
+
try {
|
|
558
|
+
const url = port
|
|
559
|
+
? `http://localhost:${port}/api/health`
|
|
560
|
+
: serverUrl("/api/health");
|
|
561
|
+
const res = await fetch(url, {
|
|
562
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
563
|
+
});
|
|
564
|
+
if (!res.ok) return null;
|
|
565
|
+
const data = (await res.json()) as { buildHash?: string };
|
|
566
|
+
return data.buildHash ?? null;
|
|
567
|
+
} catch {
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
546
572
|
async function spawnServer(port: number): Promise<boolean> {
|
|
547
573
|
// Resolve the bun executable path. We cannot use process.execPath because
|
|
548
574
|
// when running inside OpenCode, that points to the OpenCode Go binary,
|
|
@@ -556,17 +582,28 @@ async function spawnServer(port: number): Promise<boolean> {
|
|
|
556
582
|
log(`Server not running, starting on port ${port}...`);
|
|
557
583
|
log(`Spawning: ${bunPath} run ${SERVER_ENTRY}`);
|
|
558
584
|
|
|
585
|
+
let logFd: number | undefined;
|
|
586
|
+
try {
|
|
587
|
+
logFd = openLogFile();
|
|
588
|
+
} catch {
|
|
589
|
+
// If log file can't be opened, fall back to ignore
|
|
590
|
+
}
|
|
591
|
+
|
|
559
592
|
try {
|
|
560
593
|
const proc = Bun.spawn([bunPath, "run", SERVER_ENTRY], {
|
|
561
594
|
detached: true,
|
|
562
|
-
stdio: ["ignore", "ignore", "ignore"],
|
|
595
|
+
stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore"],
|
|
563
596
|
env: { ...process.env, DASHBOARD_PORT: String(port) },
|
|
564
597
|
});
|
|
565
598
|
proc.unref();
|
|
566
|
-
log(`Spawned server process (PID: ${proc.pid})`);
|
|
599
|
+
log(`Spawned server process (PID: ${proc.pid}), logs: ${getLogFilePath()}`);
|
|
567
600
|
} catch (err) {
|
|
568
601
|
logError(`Failed to spawn server process:`, err);
|
|
569
602
|
return false;
|
|
603
|
+
} finally {
|
|
604
|
+
if (logFd !== undefined) {
|
|
605
|
+
try { closeSync(logFd); } catch {}
|
|
606
|
+
}
|
|
570
607
|
}
|
|
571
608
|
|
|
572
609
|
// Poll for readiness
|
|
@@ -945,6 +982,12 @@ async function startupSequence(
|
|
|
945
982
|
pluginId = id;
|
|
946
983
|
serverReady = true;
|
|
947
984
|
|
|
985
|
+
// Write autostart marker so subsequent sessions auto-start the server.
|
|
986
|
+
// This ensures the dashboard "just works" across sessions without the user
|
|
987
|
+
// needing to manually call dashboard_start every time.
|
|
988
|
+
writeAutostart(serverPort);
|
|
989
|
+
log(`Wrote autostart marker (port ${serverPort})`);
|
|
990
|
+
|
|
948
991
|
// Generate and send column config
|
|
949
992
|
const columns = generateColumnConfig(discoveredAgents);
|
|
950
993
|
await pushEvent("columns:update", { columns });
|
|
@@ -1071,7 +1114,7 @@ async function getServerStatus(): Promise<string> {
|
|
|
1071
1114
|
|
|
1072
1115
|
export const DashboardPlugin: Plugin = async ({ client, directory, $ }) => {
|
|
1073
1116
|
const projectName = directory.split("/").pop() || "unknown";
|
|
1074
|
-
log(`Plugin loaded for ${projectName}
|
|
1117
|
+
log(`Plugin loaded for ${projectName} — checking for existing server`);
|
|
1075
1118
|
log(`Directory: ${directory}`);
|
|
1076
1119
|
|
|
1077
1120
|
projectPath = directory;
|
|
@@ -1113,6 +1156,97 @@ export const DashboardPlugin: Plugin = async ({ client, directory, $ }) => {
|
|
|
1113
1156
|
log("Setup not detected — commands not installed in either location");
|
|
1114
1157
|
}
|
|
1115
1158
|
|
|
1159
|
+
// Auto-connect to existing server if one is already running,
|
|
1160
|
+
// or auto-start a new server if the user previously activated the dashboard.
|
|
1161
|
+
// This enables event flow (agent:active, bead:stage, etc.) without
|
|
1162
|
+
// requiring the user to manually call dashboard_start each session.
|
|
1163
|
+
// Fire-and-forget: don't block plugin load / hook registration.
|
|
1164
|
+
(async () => {
|
|
1165
|
+
try {
|
|
1166
|
+
let existing = await isServerRunning(true);
|
|
1167
|
+
if (!existing) {
|
|
1168
|
+
// No server running — check if the user previously activated the dashboard
|
|
1169
|
+
const autostart = readAutostart();
|
|
1170
|
+
if (autostart) {
|
|
1171
|
+
log("No server found but autostart marker exists — spawning server");
|
|
1172
|
+
const port = autostart.port || DEFAULT_PORT;
|
|
1173
|
+
const started = await spawnServer(port);
|
|
1174
|
+
if (started) {
|
|
1175
|
+
existing = await isServerRunning(true);
|
|
1176
|
+
if (!existing) {
|
|
1177
|
+
warn("Server spawned but PID file not found after autostart");
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
log(`Auto-started server on port ${existing.port} (from autostart marker)`);
|
|
1181
|
+
} else {
|
|
1182
|
+
warn("Failed to auto-start server from autostart marker");
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
} else {
|
|
1186
|
+
log("No existing server found — staying dormant");
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Version check: compare server's build hash with current code on disk.
|
|
1192
|
+
// If they differ, the server is running stale code and must be restarted.
|
|
1193
|
+
const expectedHash = computeBuildHash();
|
|
1194
|
+
const serverHash = await getServerBuildHash(existing.port);
|
|
1195
|
+
|
|
1196
|
+
if (serverHash && serverHash !== expectedHash) {
|
|
1197
|
+
log(`Server code is stale (server: ${serverHash}, expected: ${expectedHash}) — restarting`);
|
|
1198
|
+
// Kill the old server
|
|
1199
|
+
try {
|
|
1200
|
+
process.kill(existing.pid, "SIGTERM");
|
|
1201
|
+
removePid();
|
|
1202
|
+
} catch { /* ignore — process may already be gone */ }
|
|
1203
|
+
|
|
1204
|
+
// Wait briefly for the old server to die
|
|
1205
|
+
await Bun.sleep(1000);
|
|
1206
|
+
|
|
1207
|
+
// Spawn a fresh server on the same port
|
|
1208
|
+
const started = await spawnServer(existing.port);
|
|
1209
|
+
if (!started) {
|
|
1210
|
+
warn("Failed to restart server after detecting stale code");
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
log("Server restarted with fresh code");
|
|
1214
|
+
|
|
1215
|
+
// Re-read PID data since we spawned a new server
|
|
1216
|
+
existing = await isServerRunning(true);
|
|
1217
|
+
if (!existing) {
|
|
1218
|
+
warn("Server restarted but PID file not found");
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
} else if (serverHash) {
|
|
1222
|
+
log(`Server code is current (hash: ${serverHash})`);
|
|
1223
|
+
} else {
|
|
1224
|
+
log("Server does not report build hash — skipping version check");
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
log(`Found existing server (PID: ${existing.pid}, port: ${existing.port}) — auto-connecting`);
|
|
1228
|
+
|
|
1229
|
+
// Discover agents first (startupSequence needs discoveredAgents populated)
|
|
1230
|
+
discoveredAgents = await discoverAllAgents(directory);
|
|
1231
|
+
hasPipelineAgents = discoveredAgents.some((a) => a.name in PIPELINE_AGENT_ORDER);
|
|
1232
|
+
log(
|
|
1233
|
+
`Discovered ${discoveredAgents.length} agent(s)` +
|
|
1234
|
+
(hasPipelineAgents ? " (pipeline agents present)" : " (no pipeline agents)"),
|
|
1235
|
+
);
|
|
1236
|
+
|
|
1237
|
+
const result = await startupSequence(directory, projectName, $, existing.port);
|
|
1238
|
+
if (serverReady) {
|
|
1239
|
+
activated = true;
|
|
1240
|
+
toast(`Auto-connected to dashboard at http://localhost:${existing.port}`, "success", "Dashboard");
|
|
1241
|
+
log(`Auto-connect succeeded: ${result}`);
|
|
1242
|
+
} else {
|
|
1243
|
+
log(`Auto-connect failed: ${result}`);
|
|
1244
|
+
}
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
warn("Auto-connect error:", err);
|
|
1247
|
+
}
|
|
1248
|
+
})();
|
|
1249
|
+
|
|
1116
1250
|
return {
|
|
1117
1251
|
// ─── Custom Tools (LLM-callable) ──────────────────────────
|
|
1118
1252
|
|
|
@@ -1132,9 +1266,53 @@ export const DashboardPlugin: Plugin = async ({ client, directory, $ }) => {
|
|
|
1132
1266
|
args.port ?? (Number(process.env.DASHBOARD_PORT) || DEFAULT_PORT);
|
|
1133
1267
|
|
|
1134
1268
|
// Check if already running
|
|
1135
|
-
|
|
1269
|
+
let existing = await isServerRunning(true);
|
|
1136
1270
|
if (existing) {
|
|
1137
1271
|
serverPort = existing.port;
|
|
1272
|
+
|
|
1273
|
+
// Version check: restart stale server
|
|
1274
|
+
const expectedHash = computeBuildHash();
|
|
1275
|
+
const serverHash = await getServerBuildHash(existing.port);
|
|
1276
|
+
|
|
1277
|
+
if (serverHash && serverHash !== expectedHash) {
|
|
1278
|
+
log(`dashboard_start: server is stale (server: ${serverHash}, expected: ${expectedHash}) — restarting`);
|
|
1279
|
+
|
|
1280
|
+
// Deregister from old server if currently connected
|
|
1281
|
+
if (activated) {
|
|
1282
|
+
stopHeartbeat();
|
|
1283
|
+
await deregisterFromServer();
|
|
1284
|
+
serverReady = false;
|
|
1285
|
+
activated = false;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Kill old server
|
|
1289
|
+
try {
|
|
1290
|
+
process.kill(existing.pid, "SIGTERM");
|
|
1291
|
+
removePid();
|
|
1292
|
+
} catch { /* ignore — process may already be gone */ }
|
|
1293
|
+
|
|
1294
|
+
await Bun.sleep(1000);
|
|
1295
|
+
|
|
1296
|
+
// Spawn fresh and connect
|
|
1297
|
+
activating = true;
|
|
1298
|
+
try {
|
|
1299
|
+
const result = await startupSequence(directory, projectName, $, existing.port);
|
|
1300
|
+
activated = true;
|
|
1301
|
+
const isSuccess = serverReady;
|
|
1302
|
+
toast(
|
|
1303
|
+
isSuccess ? `Dashboard restarted (stale code detected) at http://localhost:${existing.port}` : result,
|
|
1304
|
+
isSuccess ? "success" : "warning",
|
|
1305
|
+
"Dashboard",
|
|
1306
|
+
);
|
|
1307
|
+
return isSuccess
|
|
1308
|
+
? `Dashboard restarted with fresh code at http://localhost:${existing.port}`
|
|
1309
|
+
: result;
|
|
1310
|
+
} finally {
|
|
1311
|
+
activating = false;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Server is current — connect or report already running
|
|
1138
1316
|
if (!activated) {
|
|
1139
1317
|
// Server is running but plugin isn't connected — connect now
|
|
1140
1318
|
const result = await startupSequence(directory, projectName, $, existing.port);
|
|
@@ -1167,6 +1345,10 @@ export const DashboardPlugin: Plugin = async ({ client, directory, $ }) => {
|
|
|
1167
1345
|
"Call this when the user wants to shut down the dashboard.",
|
|
1168
1346
|
args: {},
|
|
1169
1347
|
async execute() {
|
|
1348
|
+
// Remove autostart marker so the server is not auto-started
|
|
1349
|
+
// on the next session — the user explicitly wants it stopped.
|
|
1350
|
+
removeAutostart();
|
|
1351
|
+
log("Removed autostart marker (explicit stop)");
|
|
1170
1352
|
const result = await stopServer();
|
|
1171
1353
|
toast(result, "info", "Dashboard");
|
|
1172
1354
|
return result;
|
|
@@ -1240,11 +1422,55 @@ export const DashboardPlugin: Plugin = async ({ client, directory, $ }) => {
|
|
|
1240
1422
|
const model = input.model;
|
|
1241
1423
|
const agent = input.agent;
|
|
1242
1424
|
|
|
1425
|
+
log(`chat.message: session=${sessionID} agent=${agent ?? "(none)"} model=${model ?? "(unknown)"} activated=${activated} serverReady=${serverReady}`);
|
|
1426
|
+
|
|
1427
|
+
// Lazy reconnection: if this plugin session is dormant but a server
|
|
1428
|
+
// is available (started by another OpenCode session or auto-started),
|
|
1429
|
+
// connect now. This handles the multi-session scenario where session B
|
|
1430
|
+
// starts before session A starts the dashboard.
|
|
1431
|
+
if (!activated && !activating) {
|
|
1432
|
+
const pidData = readPid();
|
|
1433
|
+
if (pidData) {
|
|
1434
|
+
log("Dormant session detected available server — attempting lazy connect");
|
|
1435
|
+
// Fire-and-forget: don't block the chat.message hook
|
|
1436
|
+
activating = true;
|
|
1437
|
+
(async () => {
|
|
1438
|
+
try {
|
|
1439
|
+
discoveredAgents = await discoverAllAgents(directory);
|
|
1440
|
+
hasPipelineAgents = discoveredAgents.some((a) => a.name in PIPELINE_AGENT_ORDER);
|
|
1441
|
+
const result = await startupSequence(directory, projectName, $, pidData.port);
|
|
1442
|
+
if (serverReady) {
|
|
1443
|
+
activated = true;
|
|
1444
|
+
toast(`Auto-connected to dashboard at http://localhost:${pidData.port}`, "success", "Dashboard");
|
|
1445
|
+
log(`Lazy connect succeeded: ${result}`);
|
|
1446
|
+
} else {
|
|
1447
|
+
log(`Lazy connect failed: ${result}`);
|
|
1448
|
+
}
|
|
1449
|
+
} catch (err) {
|
|
1450
|
+
warn("Lazy connect error:", err);
|
|
1451
|
+
} finally {
|
|
1452
|
+
activating = false;
|
|
1453
|
+
}
|
|
1454
|
+
})();
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1243
1458
|
// Track current agent for bead:claimed stage tracking
|
|
1244
1459
|
if (agent) {
|
|
1245
1460
|
currentAgentName = agent;
|
|
1246
1461
|
}
|
|
1247
1462
|
|
|
1463
|
+
// Send agent:active for primary agents (not subagents tracked in sessionToAgent)
|
|
1464
|
+
// Only send if not already active for this session to avoid duplicates
|
|
1465
|
+
if (agent && serverReady && !sessionToAgent.has(sessionID) && activePrimarySession !== sessionID) {
|
|
1466
|
+
activePrimarySession = sessionID;
|
|
1467
|
+
await pushEvent("agent:active", {
|
|
1468
|
+
agent: agent,
|
|
1469
|
+
sessionId: sessionID,
|
|
1470
|
+
beadId: currentBeadId,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1248
1474
|
if (injectedSessions.has(sessionID)) return;
|
|
1249
1475
|
|
|
1250
1476
|
if (await isChildSession(client, sessionID)) {
|
|
@@ -1415,6 +1641,7 @@ export const DashboardPlugin: Plugin = async ({ client, directory, $ }) => {
|
|
|
1415
1641
|
const sessionID = props?.sessionID;
|
|
1416
1642
|
if (typeof sessionID !== "string" || !sessionID) return;
|
|
1417
1643
|
|
|
1644
|
+
// Handle subagent idle (existing behavior)
|
|
1418
1645
|
const agentType = sessionToAgent.get(sessionID);
|
|
1419
1646
|
if (agentType) {
|
|
1420
1647
|
log(`Agent idle: ${agentType} (session: ${sessionID})`);
|
|
@@ -1428,6 +1655,19 @@ export const DashboardPlugin: Plugin = async ({ client, directory, $ }) => {
|
|
|
1428
1655
|
sessionToAgent.delete(sessionID);
|
|
1429
1656
|
}
|
|
1430
1657
|
|
|
1658
|
+
// Handle primary agent idle (not a subagent, but a built-in agent like Build, Plan, etc.)
|
|
1659
|
+
if (!sessionToAgent.has(sessionID) && currentAgentName && activePrimarySession === sessionID) {
|
|
1660
|
+
log(`Primary agent idle: ${currentAgentName} (session: ${sessionID})`);
|
|
1661
|
+
activePrimarySession = null;
|
|
1662
|
+
if (serverReady) {
|
|
1663
|
+
await pushEvent("agent:idle", {
|
|
1664
|
+
agent: currentAgentName,
|
|
1665
|
+
sessionId: sessionID,
|
|
1666
|
+
beadId: currentBeadId,
|
|
1667
|
+
});
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1431
1671
|
if (serverReady) {
|
|
1432
1672
|
scheduleRefresh($);
|
|
1433
1673
|
}
|
package/server/index.ts
CHANGED
|
@@ -13,10 +13,22 @@ import {
|
|
|
13
13
|
closeAllSSEClients,
|
|
14
14
|
stateManager,
|
|
15
15
|
stopHealthMonitoring,
|
|
16
|
+
configureIdleShutdown,
|
|
17
|
+
checkAndStartIdleTimer,
|
|
16
18
|
} from "./routes";
|
|
17
19
|
import { writePid, removePid } from "./pid";
|
|
20
|
+
import { computeBuildHash } from "../shared/version";
|
|
18
21
|
|
|
19
22
|
const PORT = Number(process.env.DASHBOARD_PORT) || 3333;
|
|
23
|
+
const buildHash = computeBuildHash();
|
|
24
|
+
|
|
25
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 300_000; // 5 minutes
|
|
26
|
+
const rawIdleTimeout = process.env.DASHBOARD_IDLE_TIMEOUT_MS;
|
|
27
|
+
const parsedIdleTimeout = rawIdleTimeout ? parseInt(rawIdleTimeout, 10) : NaN;
|
|
28
|
+
const IDLE_TIMEOUT_MS =
|
|
29
|
+
Number.isFinite(parsedIdleTimeout) && parsedIdleTimeout >= 0
|
|
30
|
+
? parsedIdleTimeout
|
|
31
|
+
: DEFAULT_IDLE_TIMEOUT_MS;
|
|
20
32
|
|
|
21
33
|
const server = Bun.serve({
|
|
22
34
|
port: PORT,
|
|
@@ -25,10 +37,14 @@ const server = Bun.serve({
|
|
|
25
37
|
});
|
|
26
38
|
|
|
27
39
|
// Write PID file so plugin tools and CLI can find this server
|
|
28
|
-
writePid(process.pid, server.port ?? PORT);
|
|
40
|
+
writePid(process.pid, server.port ?? PORT, buildHash);
|
|
29
41
|
|
|
30
42
|
console.log(`[dashboard-server] Running on http://localhost:${server.port}`);
|
|
31
43
|
console.log(`[dashboard-server] PID: ${process.pid}`);
|
|
44
|
+
console.log(
|
|
45
|
+
`[dashboard-server] Idle auto-shutdown timeout: ${IDLE_TIMEOUT_MS === 0 ? "disabled" : `${IDLE_TIMEOUT_MS}ms`}`,
|
|
46
|
+
);
|
|
47
|
+
console.log(`[dashboard-server] Build hash: ${buildHash}`);
|
|
32
48
|
console.log(`[dashboard-server] Endpoints:`);
|
|
33
49
|
console.log(` POST /api/plugin/register`);
|
|
34
50
|
console.log(` POST /api/plugin/event`);
|
|
@@ -38,14 +54,18 @@ console.log(` GET /api/state`);
|
|
|
38
54
|
console.log(` GET /api/events`);
|
|
39
55
|
console.log(` GET /api/health`);
|
|
40
56
|
|
|
57
|
+
// --- Idle Auto-Shutdown ---
|
|
58
|
+
configureIdleShutdown(IDLE_TIMEOUT_MS);
|
|
59
|
+
checkAndStartIdleTimer();
|
|
60
|
+
|
|
41
61
|
// --- Graceful Shutdown ---
|
|
42
62
|
|
|
43
|
-
function shutdown() {
|
|
63
|
+
export function shutdown() {
|
|
44
64
|
console.log(`\n[dashboard-server] Shutting down...`);
|
|
45
65
|
stateManager.persistNow(); // Flush state to disk before shutdown
|
|
46
66
|
stopHealthMonitoring();
|
|
47
67
|
closeAllSSEClients();
|
|
48
|
-
removePid(); // Clean up PID file
|
|
68
|
+
removePid(process.pid); // Clean up PID file (only if it still belongs to us)
|
|
49
69
|
server.stop();
|
|
50
70
|
console.log(`[dashboard-server] Server stopped.`);
|
|
51
71
|
process.exit(0);
|
package/server/pid.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Contents: JSON { pid, port, startedAt }
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
11
|
+
import { existsSync, mkdirSync, openSync, readFileSync, statSync, truncateSync, unlinkSync, writeFileSync } from "fs";
|
|
12
12
|
import { dirname, join } from "path";
|
|
13
13
|
import { homedir } from "os";
|
|
14
14
|
|
|
@@ -18,25 +18,59 @@ export interface PidFileData {
|
|
|
18
18
|
pid: number;
|
|
19
19
|
port: number;
|
|
20
20
|
startedAt: string; // ISO timestamp
|
|
21
|
+
buildHash?: string; // 12-char hex hash of source files (optional for backward compat)
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
// --- PID file path ---
|
|
24
25
|
|
|
25
26
|
const PID_DIR = join(homedir(), ".cache", "opencode");
|
|
26
27
|
const PID_FILE = join(PID_DIR, "opencode-dashboard.pid");
|
|
28
|
+
const LOG_FILE = join(PID_DIR, "opencode-dashboard.log");
|
|
29
|
+
const AUTOSTART_FILE = join(PID_DIR, "opencode-dashboard.autostart");
|
|
30
|
+
|
|
31
|
+
const MAX_LOG_SIZE_BYTES = 1_048_576; // 1 MB
|
|
27
32
|
|
|
28
33
|
/** Get the PID file path (exposed for testing) */
|
|
29
34
|
export function getPidFilePath(): string {
|
|
30
35
|
return PID_FILE;
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
/** Get the server log file path */
|
|
39
|
+
export function getLogFilePath(): string {
|
|
40
|
+
return LOG_FILE;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Prepare the server log file for writing.
|
|
45
|
+
* Ensures the directory exists and truncates the file if it exceeds 1 MB.
|
|
46
|
+
* Returns a file descriptor opened in append mode, suitable for Bun.spawn stdio.
|
|
47
|
+
*/
|
|
48
|
+
export function openLogFile(): number {
|
|
49
|
+
if (!existsSync(PID_DIR)) {
|
|
50
|
+
mkdirSync(PID_DIR, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Simple size management: truncate if over 1 MB
|
|
54
|
+
try {
|
|
55
|
+
const st = statSync(LOG_FILE);
|
|
56
|
+
if (st.size > MAX_LOG_SIZE_BYTES) {
|
|
57
|
+
truncateSync(LOG_FILE, 0);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// File doesn't exist yet — that's fine
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Open in append mode, create if not exists
|
|
64
|
+
return openSync(LOG_FILE, "a");
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
// --- Write ---
|
|
34
68
|
|
|
35
69
|
/**
|
|
36
70
|
* Write PID file when the server starts.
|
|
37
71
|
* Creates the directory if it doesn't exist.
|
|
38
72
|
*/
|
|
39
|
-
export function writePid(pid: number, port: number): void {
|
|
73
|
+
export function writePid(pid: number, port: number, buildHash?: string): void {
|
|
40
74
|
if (!existsSync(PID_DIR)) {
|
|
41
75
|
mkdirSync(PID_DIR, { recursive: true });
|
|
42
76
|
}
|
|
@@ -44,6 +78,7 @@ export function writePid(pid: number, port: number): void {
|
|
|
44
78
|
pid,
|
|
45
79
|
port,
|
|
46
80
|
startedAt: new Date().toISOString(),
|
|
81
|
+
...(buildHash ? { buildHash } : {}),
|
|
47
82
|
};
|
|
48
83
|
writeFileSync(PID_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
49
84
|
}
|
|
@@ -71,12 +106,19 @@ export function readPid(): PidFileData | null {
|
|
|
71
106
|
|
|
72
107
|
/**
|
|
73
108
|
* Remove PID file on graceful shutdown or after stopping the server.
|
|
109
|
+
*
|
|
110
|
+
* When `ownPid` is provided, the file is only deleted if it still belongs
|
|
111
|
+
* to that process. This prevents a shutting-down server from accidentally
|
|
112
|
+
* removing a *new* server's PID file written during a restart race.
|
|
74
113
|
*/
|
|
75
|
-
export function removePid(): void {
|
|
114
|
+
export function removePid(ownPid?: number): void {
|
|
76
115
|
try {
|
|
77
|
-
if (existsSync(PID_FILE))
|
|
78
|
-
|
|
116
|
+
if (!existsSync(PID_FILE)) return;
|
|
117
|
+
if (ownPid !== undefined) {
|
|
118
|
+
const current = readPid();
|
|
119
|
+
if (current && current.pid !== ownPid) return; // PID file belongs to a newer server
|
|
79
120
|
}
|
|
121
|
+
unlinkSync(PID_FILE);
|
|
80
122
|
} catch {
|
|
81
123
|
// Ignore errors (file may already be gone)
|
|
82
124
|
}
|
|
@@ -138,3 +180,71 @@ export async function isServerRunning(
|
|
|
138
180
|
|
|
139
181
|
return pidData;
|
|
140
182
|
}
|
|
183
|
+
|
|
184
|
+
// --- Autostart Marker ---
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Autostart marker file tracks whether the user has previously activated
|
|
188
|
+
* the dashboard. When present, the plugin will auto-start the server on
|
|
189
|
+
* subsequent sessions instead of staying dormant.
|
|
190
|
+
*
|
|
191
|
+
* The marker is:
|
|
192
|
+
* - Written when the dashboard is first activated (startupSequence succeeds)
|
|
193
|
+
* - Removed when the user explicitly calls dashboard_stop
|
|
194
|
+
* - Checked during plugin auto-connect to decide whether to spawn a server
|
|
195
|
+
*/
|
|
196
|
+
|
|
197
|
+
export interface AutostartData {
|
|
198
|
+
port: number;
|
|
199
|
+
createdAt: string; // ISO timestamp
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Get the autostart marker file path (exposed for testing) */
|
|
203
|
+
export function getAutostartFilePath(): string {
|
|
204
|
+
return AUTOSTART_FILE;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Write the autostart marker to indicate the dashboard should auto-start
|
|
209
|
+
* on subsequent plugin loads.
|
|
210
|
+
*/
|
|
211
|
+
export function writeAutostart(port: number): void {
|
|
212
|
+
if (!existsSync(PID_DIR)) {
|
|
213
|
+
mkdirSync(PID_DIR, { recursive: true });
|
|
214
|
+
}
|
|
215
|
+
const data: AutostartData = {
|
|
216
|
+
port,
|
|
217
|
+
createdAt: new Date().toISOString(),
|
|
218
|
+
};
|
|
219
|
+
writeFileSync(AUTOSTART_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Read the autostart marker. Returns null if the marker doesn't exist
|
|
224
|
+
* or is malformed.
|
|
225
|
+
*/
|
|
226
|
+
export function readAutostart(): AutostartData | null {
|
|
227
|
+
try {
|
|
228
|
+
if (!existsSync(AUTOSTART_FILE)) return null;
|
|
229
|
+
const raw = readFileSync(AUTOSTART_FILE, "utf-8");
|
|
230
|
+
const parsed = JSON.parse(raw) as AutostartData;
|
|
231
|
+
if (typeof parsed.port !== "number") return null;
|
|
232
|
+
return parsed;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Remove the autostart marker (e.g. when the user explicitly stops
|
|
240
|
+
* the dashboard via dashboard_stop).
|
|
241
|
+
*/
|
|
242
|
+
export function removeAutostart(): void {
|
|
243
|
+
try {
|
|
244
|
+
if (existsSync(AUTOSTART_FILE)) {
|
|
245
|
+
unlinkSync(AUTOSTART_FILE);
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// Ignore errors (file may already be gone)
|
|
249
|
+
}
|
|
250
|
+
}
|