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/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-W-qyIr7d.js"></script>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-dashboard",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Real-time Kanban dashboard that visualizes OpenCode agent activity",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 { isServerRunning, readPid, writePid, removePid } from "../server/pid";
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} (dormant waiting for /dashboard-start)`);
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
- const existing = await isServerRunning(true);
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
- unlinkSync(PID_FILE);
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
+ }