@echomem/mcp 1.4.3 → 1.4.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/README.md +12 -7
- package/dist/hud/capsule.js +68 -25
- package/dist/hud/electron-main.js +11 -3
- package/dist/hud/metric.js +16 -3
- package/dist/hud/monitor.js +353 -15
- package/dist/hud/server.js +25 -0
- package/dist/hud/web.js +737 -428
- package/dist/index.js +14 -1
- package/dist/package-metadata.js +4 -2
- package/dist/setup.js +28 -33
- package/dist/update-check.js +154 -0
- package/dist/v1-contract.js +18 -1
- package/package.json +2 -2
package/dist/hud/monitor.js
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { adapterList, adapters } from "./adapters.js";
|
|
6
|
+
import { homePath, newestFile, walkFiles } from "./fs.js";
|
|
7
|
+
const LIVE_WINDOW_MS = 45_000;
|
|
8
|
+
const ONGOING_WINDOW_MS = 300_000; // a thread counts as "ongoing" if its log was written in the last 5 min
|
|
9
|
+
const FOCUS_GRACE_MS = 8_000; // keep the last-known frontmost client this long when detection momentarily misses
|
|
10
|
+
const RECENT_WINDOW_MS = 12 * 60 * 60 * 1000; // a session is a "recent tab" if its log was touched in the last 12h
|
|
11
|
+
const RECENT_CAP = 16; // most-recent N sessions kept as switchable tabs
|
|
5
12
|
export class HudMonitor extends EventEmitter {
|
|
6
13
|
mode;
|
|
7
14
|
pollMs;
|
|
8
15
|
timer = null;
|
|
9
16
|
signatures = new Map();
|
|
10
17
|
scores = new Map();
|
|
18
|
+
mtimes = new Map();
|
|
19
|
+
labelCache = new Map();
|
|
11
20
|
missing = [];
|
|
12
21
|
frontmostCheckedAt = 0;
|
|
22
|
+
frontmostSeenAt = 0;
|
|
13
23
|
frontmostClient = null;
|
|
14
24
|
lastActiveClient = null;
|
|
25
|
+
threadCounts = {};
|
|
26
|
+
threadCountsAt = 0;
|
|
27
|
+
recentSessions = [];
|
|
28
|
+
codexTitles = new Map();
|
|
29
|
+
codexTitlesSig = "";
|
|
30
|
+
claudeTitleCache = new Map();
|
|
31
|
+
lastPersistedRecent = "";
|
|
32
|
+
pinnedId = null;
|
|
33
|
+
pinnedScore = null;
|
|
15
34
|
constructor(mode = "auto", pollMs = 750) {
|
|
16
35
|
super();
|
|
17
36
|
this.mode = mode;
|
|
@@ -30,45 +49,297 @@ export class HudMonitor extends EventEmitter {
|
|
|
30
49
|
this.timer = null;
|
|
31
50
|
}
|
|
32
51
|
snapshot() {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
const focused = this.frontmostPreferredClient();
|
|
53
|
+
this.refreshRecent();
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const sessions = [...this.scores.values()].map((score) => {
|
|
56
|
+
// Liveness uses file mtime (real last write), not score.updatedAt — the Claude cache stamps
|
|
57
|
+
// updatedAt = now on every read, which would make an idle session look permanently live.
|
|
58
|
+
const mtimeMs = this.mtimes.get(score.client) ?? (Date.parse(score.updatedAt) || now);
|
|
59
|
+
const lastActiveMs = Math.max(0, now - mtimeMs);
|
|
60
|
+
return {
|
|
61
|
+
...score,
|
|
62
|
+
live: lastActiveMs < LIVE_WINDOW_MS,
|
|
63
|
+
focused: focused === score.client,
|
|
64
|
+
label: this.labelFor(score),
|
|
65
|
+
lastActiveMs,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
// focused first, then live (most-recent first), then idle (most-recent first).
|
|
69
|
+
sessions.sort((a, b) => {
|
|
70
|
+
if (a.focused !== b.focused)
|
|
71
|
+
return a.focused ? -1 : 1;
|
|
72
|
+
if (a.live !== b.live)
|
|
73
|
+
return a.live ? -1 : 1;
|
|
74
|
+
return a.lastActiveMs - b.lastActiveMs;
|
|
40
75
|
});
|
|
76
|
+
// A user-pinned tab overrides auto-selection: it becomes the primary and never flips away.
|
|
77
|
+
const pinned = this.pinnedView(now);
|
|
78
|
+
const ordered = pinned ? [pinned, ...sessions.filter((s) => s.sourcePath !== pinned.sourcePath)] : sessions;
|
|
41
79
|
return {
|
|
42
80
|
mode: this.mode,
|
|
43
|
-
active:
|
|
44
|
-
scores,
|
|
81
|
+
active: ordered[0] || null,
|
|
82
|
+
scores: ordered,
|
|
83
|
+
sessions: ordered,
|
|
84
|
+
threadCounts: this.threadCounts,
|
|
85
|
+
recentSessions: this.recentSessions,
|
|
45
86
|
missing: this.missing,
|
|
46
87
|
updatedAt: new Date().toISOString(),
|
|
47
88
|
};
|
|
48
89
|
}
|
|
90
|
+
labelFor(score) {
|
|
91
|
+
const cached = this.labelCache.get(score.sourcePath);
|
|
92
|
+
if (cached)
|
|
93
|
+
return cached;
|
|
94
|
+
const label = sessionLabel(score.sourcePath, score.client);
|
|
95
|
+
this.labelCache.set(score.sourcePath, label);
|
|
96
|
+
return label;
|
|
97
|
+
}
|
|
98
|
+
// Pin a recent-session id as the primary view (or null to return to auto-selection).
|
|
99
|
+
pin(id) {
|
|
100
|
+
if (id === this.pinnedId)
|
|
101
|
+
return;
|
|
102
|
+
this.pinnedId = id;
|
|
103
|
+
this.pinnedScore = null;
|
|
104
|
+
}
|
|
105
|
+
// The user-selected tab, scored on demand (cached by file signature). null if nothing is pinned or
|
|
106
|
+
// the pinned session has aged out of the recent list.
|
|
107
|
+
pinnedView(now) {
|
|
108
|
+
if (!this.pinnedId)
|
|
109
|
+
return null;
|
|
110
|
+
const rec = this.recentSessions.find((r) => r.id === this.pinnedId);
|
|
111
|
+
if (!rec)
|
|
112
|
+
return null;
|
|
113
|
+
let stat = null;
|
|
114
|
+
try {
|
|
115
|
+
stat = fs.statSync(rec.sourcePath);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const sig = `${rec.sourcePath}:${stat.size}:${stat.mtimeMs}`;
|
|
121
|
+
if (!this.pinnedScore || this.pinnedScore.sig !== sig) {
|
|
122
|
+
try {
|
|
123
|
+
this.pinnedScore = { sig, score: adapters[rec.client].score(rec.sourcePath) };
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const lastActiveMs = Math.max(0, now - stat.mtimeMs);
|
|
130
|
+
return { ...this.pinnedScore.score, live: lastActiveMs < LIVE_WINDOW_MS, focused: true, label: rec.title, lastActiveMs };
|
|
131
|
+
}
|
|
49
132
|
frontmostPreferredClient() {
|
|
50
133
|
if (this.mode !== "auto" && this.mode !== "both")
|
|
51
134
|
return null;
|
|
52
135
|
const now = Date.now();
|
|
53
136
|
if (now - this.frontmostCheckedAt > 1500) {
|
|
54
137
|
this.frontmostCheckedAt = now;
|
|
55
|
-
|
|
138
|
+
const detected = detectFrontmostClient();
|
|
139
|
+
if (detected) {
|
|
140
|
+
// Positive detection wins immediately (real focus switch, e.g. Codex → Claude Desktop).
|
|
141
|
+
this.frontmostClient = detected;
|
|
142
|
+
this.frontmostSeenAt = now;
|
|
143
|
+
}
|
|
144
|
+
else if (now - this.frontmostSeenAt > FOCUS_GRACE_MS) {
|
|
145
|
+
// Detection missed (osascript timeout, or a non-agent app like a browser is front). Hold the
|
|
146
|
+
// last-known focus for a grace window so the active slot doesn't flip to a background client
|
|
147
|
+
// just because it's writing — then release once the grace expires.
|
|
148
|
+
this.frontmostClient = null;
|
|
149
|
+
}
|
|
56
150
|
}
|
|
57
151
|
return this.frontmostClient && this.scores.has(this.frontmostClient) ? this.frontmostClient : null;
|
|
58
152
|
}
|
|
153
|
+
// One throttled walk over every session file per client → both the ongoing-thread counts and the
|
|
154
|
+
// "recent tabs" list (touched in the last RECENT_WINDOW_MS), which is persisted to disk so the tab
|
|
155
|
+
// set survives HUD restarts. Cheap at a few-second cadence; too heavy for the 750ms tick.
|
|
156
|
+
refreshRecent() {
|
|
157
|
+
const now = Date.now();
|
|
158
|
+
if (this.threadCountsAt && now - this.threadCountsAt < 2500)
|
|
159
|
+
return;
|
|
160
|
+
this.threadCountsAt = now;
|
|
161
|
+
this.loadCodexTitles();
|
|
162
|
+
const counts = {};
|
|
163
|
+
const recent = [];
|
|
164
|
+
const newestByClient = new Map();
|
|
165
|
+
for (const adapter of adapterList(this.mode)) {
|
|
166
|
+
let n = 0;
|
|
167
|
+
for (const file of adapter.findAll()) {
|
|
168
|
+
let mtimeMs = 0;
|
|
169
|
+
try {
|
|
170
|
+
mtimeMs = fs.statSync(file).mtimeMs;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
continue; // vanished mid-walk
|
|
174
|
+
}
|
|
175
|
+
const age = now - mtimeMs;
|
|
176
|
+
if (age < ONGOING_WINDOW_MS)
|
|
177
|
+
n += 1;
|
|
178
|
+
const newest = newestByClient.get(adapter.client);
|
|
179
|
+
if (!newest || mtimeMs > newest.mtimeMs)
|
|
180
|
+
newestByClient.set(adapter.client, { file, mtimeMs });
|
|
181
|
+
if (age < RECENT_WINDOW_MS)
|
|
182
|
+
recent.push(this.buildRecent(adapter.client, file, age));
|
|
183
|
+
}
|
|
184
|
+
counts[adapter.client] = n;
|
|
185
|
+
}
|
|
186
|
+
recent.sort((a, b) => a.lastActiveMs - b.lastActiveMs); // newest first
|
|
187
|
+
const list = recent.slice(0, RECENT_CAP);
|
|
188
|
+
// Always keep each agent's latest session reachable as a tab (so you can switch to "my last Codex"
|
|
189
|
+
// even if it's been idle longer than the window). Appended if the window didn't already include it.
|
|
190
|
+
for (const [client, info] of newestByClient) {
|
|
191
|
+
if (!list.some((r) => r.client === client))
|
|
192
|
+
list.push(this.buildRecent(client, info.file, now - info.mtimeMs));
|
|
193
|
+
}
|
|
194
|
+
this.threadCounts = counts;
|
|
195
|
+
this.recentSessions = list;
|
|
196
|
+
this.persistRecent();
|
|
197
|
+
}
|
|
198
|
+
buildRecent(client, file, age) {
|
|
199
|
+
return {
|
|
200
|
+
id: sessionIdFromPath(file),
|
|
201
|
+
client,
|
|
202
|
+
title: this.titleFor(client, file),
|
|
203
|
+
sourcePath: file,
|
|
204
|
+
lastActiveMs: age,
|
|
205
|
+
live: age < LIVE_WINDOW_MS,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
titleFor(client, file) {
|
|
209
|
+
if (client === "codex") {
|
|
210
|
+
const uuid = codexUuidFromPath(file);
|
|
211
|
+
const title = uuid && this.codexTitles.get(uuid);
|
|
212
|
+
if (title)
|
|
213
|
+
return title;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const title = this.claudeTitle(file);
|
|
217
|
+
if (title)
|
|
218
|
+
return title;
|
|
219
|
+
}
|
|
220
|
+
return sessionLabel(file, client); // repo/cwd basename fallback
|
|
221
|
+
}
|
|
222
|
+
// Claude has no title index, so derive one from the thread's first real user message (cached per file
|
|
223
|
+
// — the first message never changes). Distinguishes two threads in the same repo.
|
|
224
|
+
claudeTitle(file) {
|
|
225
|
+
const cached = this.claudeTitleCache.get(file);
|
|
226
|
+
if (cached !== undefined)
|
|
227
|
+
return cached;
|
|
228
|
+
let title = "";
|
|
229
|
+
try {
|
|
230
|
+
const fd = fs.openSync(file, "r");
|
|
231
|
+
try {
|
|
232
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
233
|
+
const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
234
|
+
for (const line of buf.toString("utf8", 0, bytes).split("\n")) {
|
|
235
|
+
if (!line.includes('"user"'))
|
|
236
|
+
continue;
|
|
237
|
+
let obj;
|
|
238
|
+
try {
|
|
239
|
+
obj = JSON.parse(line);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
continue; // partial/last line
|
|
243
|
+
}
|
|
244
|
+
if (!isRecord(obj) || obj.type !== "user")
|
|
245
|
+
continue;
|
|
246
|
+
const message = isRecord(obj.message) ? obj.message : null;
|
|
247
|
+
const content = message ? message.content : undefined;
|
|
248
|
+
let body = "";
|
|
249
|
+
if (typeof content === "string")
|
|
250
|
+
body = content;
|
|
251
|
+
else if (Array.isArray(content))
|
|
252
|
+
body = content.map((b) => (isRecord(b) && typeof b.text === "string" ? b.text : "")).join(" ");
|
|
253
|
+
body = body.replace(/\s+/g, " ").trim();
|
|
254
|
+
if (!body || body.startsWith("<") || /^#\s*claudeMd\b/i.test(body) || body.startsWith("Caveat:"))
|
|
255
|
+
continue;
|
|
256
|
+
title = body.length > 48 ? `${body.slice(0, 47)}…` : body;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
fs.closeSync(fd);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
/* unreadable → falls back to repo label */
|
|
266
|
+
}
|
|
267
|
+
this.claudeTitleCache.set(file, title);
|
|
268
|
+
return title;
|
|
269
|
+
}
|
|
270
|
+
// Codex writes a lightweight index of every thread (id + human title). Load it (mtime-cached) so tabs
|
|
271
|
+
// show real titles like "Verify file state" instead of a UUID.
|
|
272
|
+
loadCodexTitles() {
|
|
273
|
+
const indexPath = homePath(".codex", "session_index.jsonl");
|
|
274
|
+
let sig = "";
|
|
275
|
+
try {
|
|
276
|
+
const stat = fs.statSync(indexPath);
|
|
277
|
+
sig = `${stat.size}:${stat.mtimeMs}`;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (sig === this.codexTitlesSig)
|
|
283
|
+
return;
|
|
284
|
+
this.codexTitlesSig = sig;
|
|
285
|
+
const map = new Map();
|
|
286
|
+
try {
|
|
287
|
+
for (const line of fs.readFileSync(indexPath, "utf8").split("\n")) {
|
|
288
|
+
if (!line.trim())
|
|
289
|
+
continue;
|
|
290
|
+
try {
|
|
291
|
+
const obj = JSON.parse(line);
|
|
292
|
+
if (obj && typeof obj.id === "string" && typeof obj.thread_name === "string")
|
|
293
|
+
map.set(obj.id, obj.thread_name);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
/* skip malformed line */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
/* index unreadable — titles fall back to labels */
|
|
302
|
+
}
|
|
303
|
+
this.codexTitles = map;
|
|
304
|
+
}
|
|
305
|
+
persistRecent() {
|
|
306
|
+
const signature = JSON.stringify(this.recentSessions.map((s) => `${s.id}:${s.live}`));
|
|
307
|
+
if (signature === this.lastPersistedRecent)
|
|
308
|
+
return; // only rewrite when the set/liveness changes
|
|
309
|
+
this.lastPersistedRecent = signature;
|
|
310
|
+
try {
|
|
311
|
+
const dir = homePath(".echomem");
|
|
312
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
313
|
+
fs.writeFileSync(path.join(dir, "hud-recent-sessions.json"), JSON.stringify({ updatedAt: new Date().toISOString(), sessions: this.recentSessions }, null, 2));
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
/* best-effort — persistence never blocks the HUD */
|
|
317
|
+
}
|
|
318
|
+
}
|
|
59
319
|
tick() {
|
|
60
320
|
const missing = [];
|
|
61
321
|
let changed = false;
|
|
62
322
|
for (const adapter of adapterList(this.mode)) {
|
|
63
323
|
const file = adapter.findActive();
|
|
64
|
-
|
|
324
|
+
let stat = null;
|
|
325
|
+
if (file) {
|
|
326
|
+
try {
|
|
327
|
+
stat = fs.statSync(file);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
stat = null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (!file || !stat) {
|
|
65
334
|
missing.push(adapter.client);
|
|
66
335
|
if (this.scores.delete(adapter.client))
|
|
67
336
|
changed = true;
|
|
337
|
+
this.mtimes.delete(adapter.client);
|
|
68
338
|
continue;
|
|
69
339
|
}
|
|
70
|
-
|
|
71
|
-
|
|
340
|
+
this.mtimes.set(adapter.client, liveMtime(adapter.client, file, stat.mtimeMs));
|
|
341
|
+
const signature = `${file}:${stat.size}:${stat.mtimeMs}`;
|
|
342
|
+
if (signature === this.signatures.get(adapter.client))
|
|
72
343
|
continue;
|
|
73
344
|
this.signatures.set(adapter.client, signature);
|
|
74
345
|
try {
|
|
@@ -104,3 +375,70 @@ function detectFrontmostClient() {
|
|
|
104
375
|
}
|
|
105
376
|
return null;
|
|
106
377
|
}
|
|
378
|
+
function isRecord(value) {
|
|
379
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
380
|
+
}
|
|
381
|
+
function sessionIdFromPath(file) {
|
|
382
|
+
return codexUuidFromPath(file) || path.basename(file).replace(/\.jsonl$|\.json$/, "");
|
|
383
|
+
}
|
|
384
|
+
function codexUuidFromPath(file) {
|
|
385
|
+
const match = path.basename(file).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
|
|
386
|
+
return match ? match[0] : "";
|
|
387
|
+
}
|
|
388
|
+
function defaultLabel(client) {
|
|
389
|
+
if (client === "codex")
|
|
390
|
+
return "Codex";
|
|
391
|
+
if (client === "claude-code")
|
|
392
|
+
return "Claude Code";
|
|
393
|
+
return "Claude Desktop";
|
|
394
|
+
}
|
|
395
|
+
// Recognizable label = basename of the session's cwd. Both Codex rollouts and Claude transcripts
|
|
396
|
+
// carry a "cwd" field; the Claude Code active source is the echo-ctx cache (<sessionId>.json, no cwd),
|
|
397
|
+
// so resolve its transcript by sessionId first. Falls back to the client name when cwd is absent.
|
|
398
|
+
function sessionLabel(file, client) {
|
|
399
|
+
const target = resolveClaudeTranscript(file) ?? file;
|
|
400
|
+
const cwd = peekCwd(target);
|
|
401
|
+
return cwd ? path.basename(cwd) : defaultLabel(client);
|
|
402
|
+
}
|
|
403
|
+
// The Claude Code active source is the echo-ctx cache (<sessionId>.json), whose mtime only bumps when
|
|
404
|
+
// the statusline re-renders — stale during a long turn. The transcript grows every tool call, so it's
|
|
405
|
+
// the true liveness signal. Take the fresher of the two. Cached per cache-file (paths are stable).
|
|
406
|
+
function liveMtime(client, file, cacheMtimeMs) {
|
|
407
|
+
const transcript = client === "claude-code" ? resolveClaudeTranscript(file) : null;
|
|
408
|
+
if (!transcript)
|
|
409
|
+
return cacheMtimeMs;
|
|
410
|
+
try {
|
|
411
|
+
return Math.max(cacheMtimeMs, fs.statSync(transcript).mtimeMs);
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return cacheMtimeMs;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const transcriptCache = new Map();
|
|
418
|
+
function resolveClaudeTranscript(cacheFile) {
|
|
419
|
+
if (!cacheFile.endsWith(".json") || !cacheFile.includes(`${path.sep}echo-ctx${path.sep}`))
|
|
420
|
+
return null;
|
|
421
|
+
if (transcriptCache.has(cacheFile))
|
|
422
|
+
return transcriptCache.get(cacheFile) ?? null;
|
|
423
|
+
const sessionId = path.basename(cacheFile, ".json");
|
|
424
|
+
const transcript = newestFile(walkFiles(homePath(".claude", "projects"), (f) => path.basename(f) === `${sessionId}.jsonl`));
|
|
425
|
+
transcriptCache.set(cacheFile, transcript);
|
|
426
|
+
return transcript;
|
|
427
|
+
}
|
|
428
|
+
function peekCwd(file) {
|
|
429
|
+
try {
|
|
430
|
+
const fd = fs.openSync(file, "r");
|
|
431
|
+
try {
|
|
432
|
+
const buf = Buffer.alloc(256 * 1024);
|
|
433
|
+
const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
434
|
+
const match = buf.toString("utf8", 0, bytes).match(/"cwd"\s*:\s*"([^"]+)"/);
|
|
435
|
+
return match ? match[1].replace(/\\\//g, "/") : null;
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
fs.closeSync(fd);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
package/dist/hud/server.js
CHANGED
|
@@ -36,6 +36,31 @@ export async function createHudServer(opts = {}) {
|
|
|
36
36
|
res.end(JSON.stringify(latest, null, 2));
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
|
+
if (url.pathname === "/select") {
|
|
40
|
+
const id = url.searchParams.get("id");
|
|
41
|
+
monitor.pin(id && id !== "auto" ? id : null);
|
|
42
|
+
latest = monitor.snapshot();
|
|
43
|
+
const payload = `data: ${JSON.stringify(latest)}\n\n`;
|
|
44
|
+
for (const client of clients)
|
|
45
|
+
client.write(payload);
|
|
46
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
47
|
+
res.end(JSON.stringify({ ok: true, pinned: id && id !== "auto" ? id : null }));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (url.pathname === "/reveal") {
|
|
51
|
+
const target = url.searchParams.get("path") || "";
|
|
52
|
+
// Only reveal paths we actually track — never open an arbitrary path from a query string.
|
|
53
|
+
const known = new Set([
|
|
54
|
+
...(latest.recentSessions || []).map((s) => s.sourcePath),
|
|
55
|
+
...(latest.sessions || []).map((s) => s.sourcePath),
|
|
56
|
+
]);
|
|
57
|
+
const canReveal = Boolean(target) && known.has(target) && typeof opts.onReveal === "function";
|
|
58
|
+
if (canReveal)
|
|
59
|
+
opts.onReveal(target);
|
|
60
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
61
|
+
res.end(JSON.stringify({ ok: canReveal, reason: canReveal ? undefined : opts.onReveal ? "unknown_path" : "no_desktop" }));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
39
64
|
if (url.pathname === "/capsule") {
|
|
40
65
|
const active = latest.active;
|
|
41
66
|
if (!active) {
|