@ammduncan/easel 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.
@@ -0,0 +1,36 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { DATA_ROOT } from "./paths.js";
4
+ const CONFIG_PATH = join(DATA_ROOT, "config.json");
5
+ const DEFAULT = { preset: "paper", theme: "dark", density: "carded" };
6
+ const PRESETS = ["paper", "aurora", "slate"];
7
+ const THEMES = ["light", "dark"];
8
+ const DENSITIES = ["carded", "flat"];
9
+ function coerce(raw) {
10
+ const c = (raw && typeof raw === "object" ? raw : {});
11
+ const preset = PRESETS.includes(c.preset) ? c.preset : DEFAULT.preset;
12
+ const theme = THEMES.includes(c.theme) ? c.theme : DEFAULT.theme;
13
+ const density = DENSITIES.includes(c.density)
14
+ ? c.density
15
+ : DEFAULT.density;
16
+ return { preset, theme, density };
17
+ }
18
+ export function readConfig() {
19
+ if (!existsSync(CONFIG_PATH))
20
+ return { ...DEFAULT };
21
+ try {
22
+ return coerce(JSON.parse(readFileSync(CONFIG_PATH, "utf-8")));
23
+ }
24
+ catch {
25
+ return { ...DEFAULT };
26
+ }
27
+ }
28
+ export function writeConfig(patch) {
29
+ const current = readConfig();
30
+ const next = coerce({ ...current, ...patch });
31
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
32
+ writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2));
33
+ return next;
34
+ }
35
+ export const ALL_PRESETS = PRESETS;
36
+ export const ALL_THEMES = THEMES;
@@ -0,0 +1,2 @@
1
+ import { startHttpServer } from "./http-server.js";
2
+ startHttpServer();
@@ -0,0 +1,202 @@
1
+ import express from "express";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { appendPush, deletePush, deleteSession, getSessionView, listSessionSummaries, registerSession, sessionExists, sweepIdleSessions, touchSession, updateSessionMeta, } from "./session-store.js";
6
+ import { readConfig, writeConfig } from "./config-store.js";
7
+ import { clearLockIfMine, writeLock } from "./server-manager.js";
8
+ import { resolvePort } from "./paths.js";
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const CLIENT_DIR = resolve(__dirname, "client");
11
+ const clients = new Map();
12
+ let nextClientId = 1;
13
+ function broadcast(sessionId, event, payload) {
14
+ const data = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
15
+ for (const c of clients.values()) {
16
+ if (c.sessionId === sessionId) {
17
+ try {
18
+ c.res.write(data);
19
+ }
20
+ catch {
21
+ /* client gone — cleanup on next disconnect */
22
+ }
23
+ }
24
+ }
25
+ }
26
+ function broadcastAll(event, payload) {
27
+ const data = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
28
+ for (const c of clients.values()) {
29
+ try {
30
+ c.res.write(data);
31
+ }
32
+ catch {
33
+ /* swallow */
34
+ }
35
+ }
36
+ }
37
+ function renderViewerHtml(sessionId, port) {
38
+ const tpl = readFileSync(resolve(CLIENT_DIR, "viewer.html"), "utf-8");
39
+ return tpl
40
+ .replace(/__SESSION_ID__/g, sessionId)
41
+ .replace(/__PORT__/g, String(port));
42
+ }
43
+ function renderIndexHtml(port) {
44
+ const tpl = readFileSync(resolve(CLIENT_DIR, "index.html"), "utf-8");
45
+ return tpl.replace(/__PORT__/g, String(port));
46
+ }
47
+ export function startHttpServer() {
48
+ const port = resolvePort();
49
+ const app = express();
50
+ app.use(express.json({ limit: "8mb" }));
51
+ app.use("/static", express.static(CLIENT_DIR, {
52
+ fallthrough: false,
53
+ maxAge: "0",
54
+ }));
55
+ app.get("/health", (_req, res) => {
56
+ res.json({ ok: true, pid: process.pid, port });
57
+ });
58
+ app.get("/", (_req, res) => {
59
+ res.type("text/html").send(renderIndexHtml(port));
60
+ });
61
+ app.get("/api/presence", (_req, res) => {
62
+ res.json({ tabs: clients.size });
63
+ });
64
+ app.get("/api/sessions", (_req, res) => {
65
+ res.json({ sessions: listSessionSummaries() });
66
+ });
67
+ app.get("/api/config", (_req, res) => {
68
+ res.json({ config: readConfig() });
69
+ });
70
+ app.post("/api/config", (req, res) => {
71
+ const { preset, theme, density } = req.body ?? {};
72
+ const patch = {};
73
+ if (typeof preset === "string")
74
+ patch.preset = preset;
75
+ if (typeof theme === "string")
76
+ patch.theme = theme;
77
+ if (typeof density === "string")
78
+ patch.density = density;
79
+ const next = writeConfig(patch);
80
+ broadcastAll("config", next);
81
+ res.json({ config: next });
82
+ });
83
+ app.post("/api/register", (req, res) => {
84
+ const { sessionId, cwd, label } = req.body ?? {};
85
+ if (typeof sessionId !== "string" || !sessionId.trim()) {
86
+ res.status(400).json({ error: "sessionId required" });
87
+ return;
88
+ }
89
+ registerSession(sessionId);
90
+ const patch = {};
91
+ if (typeof cwd === "string")
92
+ patch.cwd = cwd;
93
+ if (typeof label === "string")
94
+ patch.label = label;
95
+ const meta = Object.keys(patch).length
96
+ ? updateSessionMeta(sessionId, patch)
97
+ : getSessionView(sessionId).meta;
98
+ res.json({ ok: true, meta });
99
+ });
100
+ app.get("/s/:id", (req, res) => {
101
+ const id = String(req.params.id);
102
+ registerSession(id);
103
+ res.type("text/html").send(renderViewerHtml(id, port));
104
+ });
105
+ app.get("/s/:id/state", (req, res) => {
106
+ const id = String(req.params.id);
107
+ if (!sessionExists(id)) {
108
+ registerSession(id);
109
+ }
110
+ res.json(getSessionView(id));
111
+ });
112
+ app.get("/s/:id/events", (req, res) => {
113
+ const id = String(req.params.id);
114
+ res.set({
115
+ "Content-Type": "text/event-stream",
116
+ "Cache-Control": "no-cache, no-transform",
117
+ Connection: "keep-alive",
118
+ "X-Accel-Buffering": "no",
119
+ });
120
+ res.flushHeaders();
121
+ res.write(`event: hello\ndata: ${JSON.stringify({ sessionId: id, config: readConfig() })}\n\n`);
122
+ const client = { id: nextClientId++, sessionId: id, res };
123
+ clients.set(client.id, client);
124
+ const ka = setInterval(() => {
125
+ try {
126
+ res.write(`: keep-alive ${Date.now()}\n\n`);
127
+ }
128
+ catch {
129
+ /* ignore */
130
+ }
131
+ }, 25_000);
132
+ req.on("close", () => {
133
+ clearInterval(ka);
134
+ clients.delete(client.id);
135
+ });
136
+ });
137
+ app.delete("/api/sessions/:id/pushes/:pushId", (req, res) => {
138
+ const id = String(req.params.id);
139
+ const pushId = String(req.params.pushId);
140
+ const ok = deletePush(id, pushId);
141
+ if (ok)
142
+ broadcast(id, "remove", { pushId });
143
+ res.json({ ok });
144
+ });
145
+ app.delete("/api/sessions/:id", (req, res) => {
146
+ const id = String(req.params.id);
147
+ const ok = deleteSession(id);
148
+ res.json({ ok });
149
+ });
150
+ app.post("/api/push", (req, res) => {
151
+ const { sessionId, html, title, kind } = req.body ?? {};
152
+ if (typeof sessionId !== "string" || !sessionId.trim()) {
153
+ res.status(400).json({ error: "sessionId required" });
154
+ return;
155
+ }
156
+ if (typeof html !== "string" || !html.length) {
157
+ res.status(400).json({ error: "html required" });
158
+ return;
159
+ }
160
+ const push = appendPush(sessionId, { html, title, kind });
161
+ touchSession(sessionId);
162
+ broadcast(sessionId, "push", push);
163
+ if (Math.random() < 0.05) {
164
+ sweepIdleSessions();
165
+ }
166
+ let sessionTabs = 0;
167
+ for (const c of clients.values()) {
168
+ if (c.sessionId === sessionId)
169
+ sessionTabs++;
170
+ }
171
+ res.json({
172
+ url: `http://localhost:${port}/s/${sessionId}`,
173
+ slide_id: push.id,
174
+ index: push.index,
175
+ sessionTabs,
176
+ });
177
+ });
178
+ const server = app.listen(port, "127.0.0.1", () => {
179
+ writeLock(port);
180
+ sweepIdleSessions();
181
+ });
182
+ // Periodic GC of idle sessions every 10 minutes (in addition to the
183
+ // probabilistic sweep on each push). Without this, low-traffic servers
184
+ // can hoard sessions long past the 24h TTL.
185
+ const sweepTimer = setInterval(() => {
186
+ try {
187
+ sweepIdleSessions();
188
+ }
189
+ catch {
190
+ /* swallow */
191
+ }
192
+ }, 10 * 60 * 1000);
193
+ sweepTimer.unref();
194
+ const shutdown = () => {
195
+ clearLockIfMine();
196
+ server.close(() => process.exit(0));
197
+ setTimeout(() => process.exit(0), 1500).unref();
198
+ };
199
+ process.on("SIGTERM", shutdown);
200
+ process.on("SIGINT", shutdown);
201
+ process.on("exit", clearLockIfMine);
202
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { ensureHttpServer } from "./server-manager.js";
9
+ import { resolveClaudeSessionId } from "./session-id.js";
10
+ import { HOOK_DIR } from "./paths.js";
11
+ function openUrlInBrowser(url) {
12
+ const platform = process.platform;
13
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
14
+ const args = platform === "win32" ? ["", url] : [url];
15
+ try {
16
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
17
+ child.unref();
18
+ }
19
+ catch {
20
+ /* swallow */
21
+ }
22
+ }
23
+ const TOOL_PUSH = "push";
24
+ const TOOL_OPEN = "open";
25
+ const TOOL_CONFIG = "config";
26
+ const TOOL_LABEL = "label";
27
+ /**
28
+ * True if a SessionStart hook (e.g. Claude Code's) has already written a
29
+ * session-id file for this MCP child's PPID. Tells us a hook-aware client is
30
+ * managing tab lifecycle, so the MCP server should NOT also auto-open.
31
+ */
32
+ function hookHasFiredForThisPpid() {
33
+ return existsSync(join(HOOK_DIR, `cc-session-${process.ppid}.txt`));
34
+ }
35
+ // One-shot guard: only auto-open once per MCP-child lifetime. If the user
36
+ // closes the tab afterwards, subsequent pushes won't re-open it.
37
+ let autoOpenAttempted = false;
38
+ function maybeAutoOpenTab(url) {
39
+ if (autoOpenAttempted)
40
+ return;
41
+ autoOpenAttempted = true;
42
+ if (hookHasFiredForThisPpid())
43
+ return; // Claude Code already opened it
44
+ openUrlInBrowser(url);
45
+ }
46
+ const inputSchema = {
47
+ type: "object",
48
+ properties: {
49
+ html: {
50
+ type: "string",
51
+ description: "HTML body to render. Sandboxed in an iframe (allow-scripts). Style for off-white background; assume Rule 30 typography defaults are injected.",
52
+ },
53
+ title: {
54
+ type: "string",
55
+ description: "Short title shown in the card header.",
56
+ },
57
+ kind: {
58
+ type: "string",
59
+ description: "Freeform tag: mockup, diff, explanation, comparison, diagram, status, progress, etc.",
60
+ },
61
+ },
62
+ required: ["html"],
63
+ additionalProperties: false,
64
+ };
65
+ async function pushToServer(args) {
66
+ const r = await fetch(`http://127.0.0.1:${args.port}/api/push`, {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify({
70
+ sessionId: args.sessionId,
71
+ html: args.html,
72
+ title: args.title,
73
+ kind: args.kind,
74
+ }),
75
+ });
76
+ if (!r.ok) {
77
+ const text = await r.text();
78
+ throw new Error(`display_push HTTP ${r.status}: ${text}`);
79
+ }
80
+ return (await r.json());
81
+ }
82
+ async function main() {
83
+ const server = new Server({ name: "easel", version: "0.1.0" }, { capabilities: { tools: {} } });
84
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
85
+ tools: [
86
+ {
87
+ name: TOOL_PUSH,
88
+ description: "Push an HTML card to this session's live browser tab (easel). Every card appends to a single scrolling page that the user keeps open in split-screen. Use proactively (per global Rule 33) for wordy explanations, mockups, diagrams, diffs, ≥3-option comparisons, or progress views — do NOT ask permission. Pass full HTML (not Markdown).",
89
+ inputSchema,
90
+ },
91
+ {
92
+ name: TOOL_OPEN,
93
+ description: "Force-open a fresh browser tab for the current easel session. Call this when the user asks for a new window, side-by-side view, or to re-open a closed tab. The default SessionStart hook only opens a tab if none are alive; this tool overrides that.",
94
+ inputSchema: {
95
+ type: "object",
96
+ properties: {},
97
+ additionalProperties: false,
98
+ },
99
+ },
100
+ {
101
+ name: TOOL_LABEL,
102
+ description: "Set or update this Claude session's display label — a short, human phrase that names what the session is about (e.g. 'Roadworthy 401 fix', 'Bulk-add cost items redesign', 'Investigating slow query'). The label appears in the topbar, switcher dropdown, and session index, replacing the cwd basename. Call this proactively whenever the work's theme shifts so the user can navigate sessions by what they ARE, not where they live. Pass an empty string to clear back to cwd basename.",
103
+ inputSchema: {
104
+ type: "object",
105
+ properties: {
106
+ label: {
107
+ type: "string",
108
+ description: "Short human label (1–8 words). Pass empty string to clear.",
109
+ },
110
+ },
111
+ required: ["label"],
112
+ additionalProperties: false,
113
+ },
114
+ },
115
+ {
116
+ name: TOOL_CONFIG,
117
+ description: "Update the easel viewer's preset, theme, and/or density. Changes apply live across every open tab (SSE-broadcast) and persist. Presets: `paper` (pitstop warm canvas, amber accent — default), `aurora` (deep canvas + violet/blue glow halos), `slate` (cool slate, cyan accent). Themes: `light` or `dark`. Density: `carded` (default — each push is a bordered card) or `flat` (no card chrome — pushes flow as sections with whitespace between). Pass only the field(s) you want to change.",
118
+ inputSchema: {
119
+ type: "object",
120
+ properties: {
121
+ preset: {
122
+ type: "string",
123
+ enum: ["paper", "aurora", "slate"],
124
+ description: "Visual preset to apply globally.",
125
+ },
126
+ theme: {
127
+ type: "string",
128
+ enum: ["light", "dark"],
129
+ description: "Light or dark mode.",
130
+ },
131
+ density: {
132
+ type: "string",
133
+ enum: ["carded", "flat"],
134
+ description: "Layout density. `flat` removes card borders/shadow/bg and uses whitespace to separate pushes.",
135
+ },
136
+ },
137
+ additionalProperties: false,
138
+ },
139
+ },
140
+ ],
141
+ }));
142
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
143
+ const sessionId = resolveClaudeSessionId();
144
+ const { port } = await ensureHttpServer();
145
+ // Non-Claude-Code clients have no SessionStart hook to open the tab —
146
+ // we do it ourselves on the first tool call instead.
147
+ maybeAutoOpenTab(`http://localhost:${port}/s/${sessionId}`);
148
+ if (req.params.name === TOOL_OPEN) {
149
+ const url = `http://localhost:${port}/s/${sessionId}`;
150
+ openUrlInBrowser(url);
151
+ return {
152
+ content: [
153
+ {
154
+ type: "text",
155
+ text: `opened a new tab → ${url}`,
156
+ },
157
+ ],
158
+ };
159
+ }
160
+ if (req.params.name === TOOL_LABEL) {
161
+ const args = (req.params.arguments ?? {});
162
+ const label = typeof args.label === "string" ? args.label.trim() : "";
163
+ await fetch(`http://127.0.0.1:${port}/api/register`, {
164
+ method: "POST",
165
+ headers: { "content-type": "application/json" },
166
+ body: JSON.stringify({ sessionId, label }),
167
+ });
168
+ return {
169
+ content: [
170
+ {
171
+ type: "text",
172
+ text: label
173
+ ? `session labelled: ${label}`
174
+ : `session label cleared`,
175
+ },
176
+ ],
177
+ };
178
+ }
179
+ if (req.params.name === TOOL_CONFIG) {
180
+ const args = (req.params.arguments ?? {});
181
+ const body = {};
182
+ if (args.preset)
183
+ body.preset = args.preset;
184
+ if (args.theme)
185
+ body.theme = args.theme;
186
+ if (args.density)
187
+ body.density = args.density;
188
+ const r = await fetch(`http://127.0.0.1:${port}/api/config`, {
189
+ method: "POST",
190
+ headers: { "content-type": "application/json" },
191
+ body: JSON.stringify(body),
192
+ });
193
+ const data = (await r.json());
194
+ return {
195
+ content: [
196
+ {
197
+ type: "text",
198
+ text: `display config now ${JSON.stringify(data.config)}`,
199
+ },
200
+ ],
201
+ };
202
+ }
203
+ if (req.params.name !== TOOL_PUSH) {
204
+ throw new Error(`unknown tool: ${req.params.name}`);
205
+ }
206
+ const args = (req.params.arguments ?? {});
207
+ if (typeof args.html !== "string" || !args.html.length) {
208
+ throw new Error("easel.push: `html` is required");
209
+ }
210
+ const result = await pushToServer({
211
+ sessionId,
212
+ html: args.html,
213
+ title: args.title,
214
+ kind: args.kind,
215
+ port,
216
+ });
217
+ const tabHint = result.sessionTabs === 0
218
+ ? " · NO TAB OPEN for this session — ask the user if you should open one (call `open`)"
219
+ : "";
220
+ return {
221
+ content: [
222
+ {
223
+ type: "text",
224
+ text: `pushed #${result.index} → ${result.url}${tabHint}`,
225
+ },
226
+ ],
227
+ };
228
+ });
229
+ const transport = new StdioServerTransport();
230
+ await server.connect(transport);
231
+ }
232
+ main().catch((err) => {
233
+ console.error("[easel mcp] fatal:", err);
234
+ process.exit(1);
235
+ });
package/dist/paths.js ADDED
@@ -0,0 +1,45 @@
1
+ import { existsSync, renameSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /** Root for runtime state — sessions, lockfile, hook session-id files. */
5
+ export const DATA_ROOT = join(homedir(), ".easel");
6
+ /** Directory the SessionStart hook writes per-PPID session-id files into. */
7
+ export const HOOK_DIR = join(DATA_ROOT, "hook");
8
+ /** Directory containing one folder per session. */
9
+ export const SESSIONS_DIR = join(DATA_ROOT, "sessions");
10
+ /** Lockfile coordinating which process owns the shared HTTP server. */
11
+ export const LOCK_FILE = join(DATA_ROOT, "server.lock");
12
+ /** Default HTTP port — overridable via EASEL_PORT (legacy: CLAUDE_DISPLAY_PORT). */
13
+ export const DEFAULT_PORT = 7878;
14
+ /** Max pushes retained per session before oldest is evicted. */
15
+ export const MAX_PUSHES_PER_SESSION = 50;
16
+ /** Idle TTL for sessions, in ms (24h). */
17
+ export const SESSION_IDLE_TTL_MS = 24 * 60 * 60 * 1000;
18
+ const LEGACY_DATA_ROOT = join(homedir(), ".claude-display");
19
+ let migrationChecked = false;
20
+ /**
21
+ * One-time migration from the project's prior name. Renames `~/.claude-display`
22
+ * to `~/.easel` if the legacy dir exists and the new dir doesn't. Safe to call
23
+ * repeatedly — short-circuits after the first call.
24
+ */
25
+ export function migrateLegacyDataRoot() {
26
+ if (migrationChecked)
27
+ return;
28
+ migrationChecked = true;
29
+ if (existsSync(DATA_ROOT))
30
+ return;
31
+ if (!existsSync(LEGACY_DATA_ROOT))
32
+ return;
33
+ try {
34
+ renameSync(LEGACY_DATA_ROOT, DATA_ROOT);
35
+ }
36
+ catch {
37
+ // best-effort; the caller will mkdirSync DATA_ROOT and proceed
38
+ }
39
+ }
40
+ /** Read the configured port, preferring EASEL_PORT and falling back to the legacy env var. */
41
+ export function resolvePort() {
42
+ return (Number(process.env.EASEL_PORT) ||
43
+ Number(process.env.CLAUDE_DISPLAY_PORT) ||
44
+ DEFAULT_PORT);
45
+ }
@@ -0,0 +1,94 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, resolve } from "node:path";
5
+ import { DATA_ROOT, LOCK_FILE, migrateLegacyDataRoot, resolvePort, } from "./paths.js";
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ export function readLock() {
8
+ if (!existsSync(LOCK_FILE))
9
+ return undefined;
10
+ try {
11
+ return JSON.parse(readFileSync(LOCK_FILE, "utf-8"));
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ function pidAlive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ async function serverResponding(port) {
27
+ try {
28
+ const r = await fetch(`http://127.0.0.1:${port}/health`, {
29
+ signal: AbortSignal.timeout(800),
30
+ });
31
+ return r.ok;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ /**
38
+ * Ensure exactly one HTTP server is running and return its port. If the
39
+ * lockfile points at a live, responding pid we reuse it; otherwise we spawn
40
+ * a detached child running `dist/http-entry.js` and wait for /health.
41
+ */
42
+ export async function ensureHttpServer() {
43
+ migrateLegacyDataRoot();
44
+ mkdirSync(DATA_ROOT, { recursive: true });
45
+ const existing = readLock();
46
+ if (existing && pidAlive(existing.pid) && (await serverResponding(existing.port))) {
47
+ return { port: existing.port, reused: true };
48
+ }
49
+ if (existing) {
50
+ try {
51
+ rmSync(LOCK_FILE);
52
+ }
53
+ catch {
54
+ /* swallow */
55
+ }
56
+ }
57
+ const port = resolvePort();
58
+ const entry = resolve(__dirname, "http-entry.js");
59
+ const child = spawn(process.execPath, [entry], {
60
+ detached: true,
61
+ stdio: ["ignore", "ignore", "ignore"],
62
+ env: { ...process.env, EASEL_PORT: String(port) },
63
+ });
64
+ child.unref();
65
+ const deadline = Date.now() + 4000;
66
+ while (Date.now() < deadline) {
67
+ if (await serverResponding(port)) {
68
+ return { port, reused: false };
69
+ }
70
+ await new Promise((r) => setTimeout(r, 120));
71
+ }
72
+ throw new Error(`easel HTTP server failed to start on port ${port}`);
73
+ }
74
+ export function writeLock(port) {
75
+ migrateLegacyDataRoot();
76
+ mkdirSync(DATA_ROOT, { recursive: true });
77
+ const record = {
78
+ pid: process.pid,
79
+ port,
80
+ startedAt: Date.now(),
81
+ };
82
+ writeFileSync(LOCK_FILE, JSON.stringify(record, null, 2));
83
+ }
84
+ export function clearLockIfMine() {
85
+ const lock = readLock();
86
+ if (lock && lock.pid === process.pid) {
87
+ try {
88
+ rmSync(LOCK_FILE);
89
+ }
90
+ catch {
91
+ /* swallow */
92
+ }
93
+ }
94
+ }