@echomem/mcp 1.4.4 → 1.4.6
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/hud/electron-main.js +11 -3
- package/dist/hud/monitor.js +212 -12
- package/dist/hud/server.js +25 -0
- package/dist/hud/web.js +161 -16
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { app, BrowserWindow, ipcMain, Menu, screen } from "electron";
|
|
5
|
+
import { app, BrowserWindow, ipcMain, Menu, screen, shell } from "electron";
|
|
6
6
|
import { createHudServer } from "./server.js";
|
|
7
7
|
const flags = parseFlags(process.argv.slice(2));
|
|
8
8
|
const mode = parseMode(flags.client);
|
|
@@ -32,8 +32,16 @@ app.whenReady().then(async () => {
|
|
|
32
32
|
writeBounds(win);
|
|
33
33
|
});
|
|
34
34
|
const preferredUrl = `http://127.0.0.1:${port}`;
|
|
35
|
+
const onReveal = (filePath) => {
|
|
36
|
+
try {
|
|
37
|
+
shell.showItemInFolder(filePath);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* best-effort */
|
|
41
|
+
}
|
|
42
|
+
};
|
|
35
43
|
try {
|
|
36
|
-
hudServer = await createHudServer({ mode, port });
|
|
44
|
+
hudServer = await createHudServer({ mode, port, onReveal });
|
|
37
45
|
createWindow(hudServer.url);
|
|
38
46
|
}
|
|
39
47
|
catch (error) {
|
|
@@ -41,7 +49,7 @@ app.whenReady().then(async () => {
|
|
|
41
49
|
createWindow(preferredUrl);
|
|
42
50
|
return;
|
|
43
51
|
}
|
|
44
|
-
hudServer = await createHudServer({ mode, port: 0 });
|
|
52
|
+
hudServer = await createHudServer({ mode, port: 0, onReveal });
|
|
45
53
|
createWindow(hudServer.url);
|
|
46
54
|
}
|
|
47
55
|
}).catch((error) => {
|
package/dist/hud/monitor.js
CHANGED
|
@@ -2,11 +2,13 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { adapterList } from "./adapters.js";
|
|
5
|
+
import { adapterList, adapters } from "./adapters.js";
|
|
6
6
|
import { homePath, newestFile, walkFiles } from "./fs.js";
|
|
7
7
|
const LIVE_WINDOW_MS = 45_000;
|
|
8
8
|
const ONGOING_WINDOW_MS = 300_000; // a thread counts as "ongoing" if its log was written in the last 5 min
|
|
9
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
|
|
10
12
|
export class HudMonitor extends EventEmitter {
|
|
11
13
|
mode;
|
|
12
14
|
pollMs;
|
|
@@ -22,6 +24,13 @@ export class HudMonitor extends EventEmitter {
|
|
|
22
24
|
lastActiveClient = null;
|
|
23
25
|
threadCounts = {};
|
|
24
26
|
threadCountsAt = 0;
|
|
27
|
+
recentSessions = [];
|
|
28
|
+
codexTitles = new Map();
|
|
29
|
+
codexTitlesSig = "";
|
|
30
|
+
claudeTitleCache = new Map();
|
|
31
|
+
lastPersistedRecent = "";
|
|
32
|
+
pinnedId = null;
|
|
33
|
+
pinnedScore = null;
|
|
25
34
|
constructor(mode = "auto", pollMs = 750) {
|
|
26
35
|
super();
|
|
27
36
|
this.mode = mode;
|
|
@@ -41,7 +50,7 @@ export class HudMonitor extends EventEmitter {
|
|
|
41
50
|
}
|
|
42
51
|
snapshot() {
|
|
43
52
|
const focused = this.frontmostPreferredClient();
|
|
44
|
-
this.
|
|
53
|
+
this.refreshRecent();
|
|
45
54
|
const now = Date.now();
|
|
46
55
|
const sessions = [...this.scores.values()].map((score) => {
|
|
47
56
|
// Liveness uses file mtime (real last write), not score.updatedAt — the Claude cache stamps
|
|
@@ -64,12 +73,16 @@ export class HudMonitor extends EventEmitter {
|
|
|
64
73
|
return a.live ? -1 : 1;
|
|
65
74
|
return a.lastActiveMs - b.lastActiveMs;
|
|
66
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;
|
|
67
79
|
return {
|
|
68
80
|
mode: this.mode,
|
|
69
|
-
active:
|
|
70
|
-
scores:
|
|
71
|
-
sessions,
|
|
81
|
+
active: ordered[0] || null,
|
|
82
|
+
scores: ordered,
|
|
83
|
+
sessions: ordered,
|
|
72
84
|
threadCounts: this.threadCounts,
|
|
85
|
+
recentSessions: this.recentSessions,
|
|
73
86
|
missing: this.missing,
|
|
74
87
|
updatedAt: new Date().toISOString(),
|
|
75
88
|
};
|
|
@@ -82,6 +95,40 @@ export class HudMonitor extends EventEmitter {
|
|
|
82
95
|
this.labelCache.set(score.sourcePath, label);
|
|
83
96
|
return label;
|
|
84
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
|
+
}
|
|
85
132
|
frontmostPreferredClient() {
|
|
86
133
|
if (this.mode !== "auto" && this.mode !== "both")
|
|
87
134
|
return null;
|
|
@@ -103,28 +150,171 @@ export class HudMonitor extends EventEmitter {
|
|
|
103
150
|
}
|
|
104
151
|
return this.frontmostClient && this.scores.has(this.frontmostClient) ? this.frontmostClient : null;
|
|
105
152
|
}
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
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() {
|
|
109
157
|
const now = Date.now();
|
|
110
|
-
if (now - this.threadCountsAt < 2500
|
|
158
|
+
if (this.threadCountsAt && now - this.threadCountsAt < 2500)
|
|
111
159
|
return;
|
|
112
160
|
this.threadCountsAt = now;
|
|
161
|
+
this.loadCodexTitles();
|
|
113
162
|
const counts = {};
|
|
163
|
+
const recent = [];
|
|
164
|
+
const newestByClient = new Map();
|
|
114
165
|
for (const adapter of adapterList(this.mode)) {
|
|
115
166
|
let n = 0;
|
|
116
167
|
for (const file of adapter.findAll()) {
|
|
168
|
+
let mtimeMs = 0;
|
|
117
169
|
try {
|
|
118
|
-
|
|
119
|
-
n += 1;
|
|
170
|
+
mtimeMs = fs.statSync(file).mtimeMs;
|
|
120
171
|
}
|
|
121
172
|
catch {
|
|
122
|
-
|
|
173
|
+
continue; // vanished mid-walk
|
|
123
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));
|
|
124
183
|
}
|
|
125
184
|
counts[adapter.client] = n;
|
|
126
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
|
+
}
|
|
127
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
|
+
}
|
|
128
318
|
}
|
|
129
319
|
tick() {
|
|
130
320
|
const missing = [];
|
|
@@ -185,6 +375,16 @@ function detectFrontmostClient() {
|
|
|
185
375
|
}
|
|
186
376
|
return null;
|
|
187
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
|
+
}
|
|
188
388
|
function defaultLabel(client) {
|
|
189
389
|
if (client === "codex")
|
|
190
390
|
return "Codex";
|
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) {
|
package/dist/hud/web.js
CHANGED
|
@@ -125,8 +125,9 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
125
125
|
z-index: 2;
|
|
126
126
|
min-width: 0;
|
|
127
127
|
display: grid;
|
|
128
|
+
grid-template-columns: minmax(0, 1fr);
|
|
128
129
|
gap: 4px;
|
|
129
|
-
padding: 20px
|
|
130
|
+
padding: 20px 38px 18px 30px;
|
|
130
131
|
}
|
|
131
132
|
.dot {
|
|
132
133
|
position: absolute;
|
|
@@ -180,17 +181,46 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
180
181
|
#hud.open .details { display: block; }
|
|
181
182
|
.head {
|
|
182
183
|
display: flex;
|
|
183
|
-
justify-content:
|
|
184
|
+
justify-content: space-between;
|
|
185
|
+
align-items: baseline;
|
|
184
186
|
min-height: 15px;
|
|
185
187
|
margin-bottom: 10px;
|
|
186
188
|
}
|
|
187
|
-
.turn {
|
|
189
|
+
.turn, .upd {
|
|
188
190
|
overflow: hidden;
|
|
189
191
|
text-overflow: ellipsis;
|
|
190
192
|
white-space: nowrap;
|
|
191
193
|
font-size: 11px;
|
|
192
194
|
color: var(--faint);
|
|
193
195
|
}
|
|
196
|
+
.tabs { display: flex; gap: 6px; overflow-x: auto; margin: 2px 0 12px; padding-bottom: 2px; }
|
|
197
|
+
.tabs::-webkit-scrollbar { height: 0; }
|
|
198
|
+
.tab {
|
|
199
|
+
flex: 0 0 auto; font-size: 10.5px; line-height: 1; padding: 5px 10px; border-radius: 999px;
|
|
200
|
+
border: 1px solid rgba(0, 0, 0, .12); background: rgba(0, 0, 0, .04);
|
|
201
|
+
color: var(--faint); cursor: pointer; white-space: nowrap; font-family: inherit;
|
|
202
|
+
}
|
|
203
|
+
.tab:hover { background: rgba(0, 0, 0, .08); }
|
|
204
|
+
.tab.active { border-color: rgba(0, 0, 0, .38); background: rgba(0, 0, 0, .10); color: #111; font-weight: 700; }
|
|
205
|
+
.spin {
|
|
206
|
+
display: inline-block; width: 9px; height: 9px; margin-right: 5px; vertical-align: -1px;
|
|
207
|
+
border: 1.5px solid rgba(0, 0, 0, .2); border-top-color: #1a3a8f; border-radius: 50%;
|
|
208
|
+
animation: echospin .8s linear infinite;
|
|
209
|
+
}
|
|
210
|
+
@keyframes echospin { to { transform: rotate(360deg); } }
|
|
211
|
+
.headctl { display: flex; align-items: center; gap: 8px; }
|
|
212
|
+
.switchbtn {
|
|
213
|
+
width: 22px; height: 22px; border-radius: 50%; padding: 0; line-height: 1; font-size: 12px;
|
|
214
|
+
border: 1px solid rgba(0, 0, 0, .2); background: rgba(0, 0, 0, .05); color: var(--faint);
|
|
215
|
+
cursor: pointer; font-family: inherit;
|
|
216
|
+
}
|
|
217
|
+
.switchbtn:hover { background: rgba(0, 0, 0, .1); color: #111; }
|
|
218
|
+
.reveal {
|
|
219
|
+
margin-top: 8px; font-size: 10.5px; padding: 4px 11px; border-radius: 999px;
|
|
220
|
+
border: 1px solid rgba(0, 0, 0, .12); background: rgba(0, 0, 0, .04); color: var(--faint);
|
|
221
|
+
cursor: pointer; font-family: inherit;
|
|
222
|
+
}
|
|
223
|
+
.reveal:hover { background: rgba(0, 0, 0, .08); color: #111; }
|
|
194
224
|
.state-row {
|
|
195
225
|
display: flex;
|
|
196
226
|
align-items: center;
|
|
@@ -373,7 +403,8 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
373
403
|
</div>
|
|
374
404
|
</div>
|
|
375
405
|
<div class="details">
|
|
376
|
-
<div class="head"><span class="turn" id="turn"></span></div>
|
|
406
|
+
<div class="head"><span class="upd" id="updated"></span><span class="headctl"><span class="turn" id="turn"></span><button class="switchbtn" id="switchbtn" title="Switch agent" style="display:none">⇄</button></span></div>
|
|
407
|
+
<div class="tabs" id="tabs"></div>
|
|
377
408
|
<div class="state-row">
|
|
378
409
|
<span class="state-dot" id="stateDot"></span>
|
|
379
410
|
<span class="state-word" id="stateWord">Fresh</span>
|
|
@@ -398,6 +429,7 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
398
429
|
</div>
|
|
399
430
|
<div class="meta" id="meta"></div>
|
|
400
431
|
<div class="path" id="path"></div>
|
|
432
|
+
<button class="reveal" id="reveal" style="display:none">↗ Reveal session log in Finder</button>
|
|
401
433
|
<div class="missing" id="missing"></div>
|
|
402
434
|
</div>
|
|
403
435
|
</section>
|
|
@@ -425,13 +457,57 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
425
457
|
const copyBtn = document.getElementById("copybtn");
|
|
426
458
|
const metaEl = document.getElementById("meta");
|
|
427
459
|
const pathEl = document.getElementById("path");
|
|
460
|
+
const updatedEl = document.getElementById("updated");
|
|
461
|
+
const tabsEl = document.getElementById("tabs");
|
|
462
|
+
const switchBtn = document.getElementById("switchbtn");
|
|
463
|
+
const revealEl = document.getElementById("reveal");
|
|
428
464
|
const missing = document.getElementById("missing");
|
|
429
465
|
const hudVersion = "v${MCP_PACKAGE_VERSION}";
|
|
430
466
|
let prevTurnState = null;
|
|
431
467
|
let lastCapsule = "";
|
|
468
|
+
let activityEpoch = null;
|
|
469
|
+
let lastState = null;
|
|
470
|
+
let currentSrc = null;
|
|
471
|
+
|
|
472
|
+
// Reveal the active session's log file in Finder (Electron only; no-op in browser mode).
|
|
473
|
+
if (revealEl) revealEl.addEventListener("click", () => {
|
|
474
|
+
if (!currentSrc) return;
|
|
475
|
+
fetch("/reveal?path=" + encodeURIComponent(currentSrc)).catch(() => {});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Circular top-right toggle: jump to the next agent's newest session (Claude ⇄ Codex).
|
|
479
|
+
if (switchBtn) switchBtn.addEventListener("click", (e) => {
|
|
480
|
+
e.stopPropagation();
|
|
481
|
+
const st = lastState;
|
|
482
|
+
if (!st || !st.recentSessions) return;
|
|
483
|
+
const clients = [];
|
|
484
|
+
for (const s of st.recentSessions) if (clients.indexOf(s.client) < 0) clients.push(s.client);
|
|
485
|
+
if (clients.length < 2) return;
|
|
486
|
+
const cur = st.active ? st.active.client : clients[0];
|
|
487
|
+
const next = clients[(clients.indexOf(cur) + 1) % clients.length];
|
|
488
|
+
const target = st.recentSessions.filter((s) => s.client === next)[0]; // newest of that agent
|
|
489
|
+
if (!target) return;
|
|
490
|
+
fetch("/select?id=" + encodeURIComponent(target.id))
|
|
491
|
+
.then(() => fetch("/state"))
|
|
492
|
+
.then((r) => r.json())
|
|
493
|
+
.then(render)
|
|
494
|
+
.catch(() => {});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Click a tab → pin that session as the primary view (or "Auto" to return to auto-selection).
|
|
498
|
+
if (tabsEl) tabsEl.addEventListener("click", (e) => {
|
|
499
|
+
const btn = e.target && e.target.closest ? e.target.closest(".tab") : null;
|
|
500
|
+
if (!btn) return;
|
|
501
|
+
const id = btn.getAttribute("data-id");
|
|
502
|
+
fetch("/select?id=" + encodeURIComponent(id))
|
|
503
|
+
.then(() => fetch("/state"))
|
|
504
|
+
.then((r) => r.json())
|
|
505
|
+
.then(render)
|
|
506
|
+
.catch(() => {});
|
|
507
|
+
});
|
|
432
508
|
|
|
433
509
|
document.getElementById("toggle").addEventListener("click", (event) => {
|
|
434
|
-
if (event.target instanceof HTMLElement && event.target.closest(".drag")) return;
|
|
510
|
+
if (event.target instanceof HTMLElement && (event.target.closest(".drag") || event.target.closest(".switchbtn"))) return;
|
|
435
511
|
hud.classList.toggle("open");
|
|
436
512
|
syncHeight();
|
|
437
513
|
});
|
|
@@ -477,6 +553,13 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
477
553
|
|
|
478
554
|
function render(state) {
|
|
479
555
|
const score = state.active || state;
|
|
556
|
+
lastState = state;
|
|
557
|
+
renderTabs(state, score && score.sourcePath ? activeIdFor(state, score.sourcePath) : null);
|
|
558
|
+
if (switchBtn) {
|
|
559
|
+
const clients = [];
|
|
560
|
+
for (const s of (state.recentSessions || [])) if (clients.indexOf(s.client) < 0) clients.push(s.client);
|
|
561
|
+
switchBtn.style.display = clients.length >= 2 ? "inline-block" : "none";
|
|
562
|
+
}
|
|
480
563
|
if (!score || typeof score.usefulPct !== "number") {
|
|
481
564
|
renderIdle(state);
|
|
482
565
|
return;
|
|
@@ -489,29 +572,37 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
489
572
|
const repeatCount = score.buckets && score.buckets.range_redundant ? score.buckets.range_redundant.count || 0 : 0;
|
|
490
573
|
const edits = score.stats && score.stats.patchEdits ? Number(score.stats.patchEdits) : 0;
|
|
491
574
|
|
|
492
|
-
|
|
493
|
-
|
|
575
|
+
// A session idle for >10 min shouldn't wear the cheerful "Fresh/clean" look — its quality
|
|
576
|
+
// numbers are stale. Show it muted as Idle so a 47h-old pinned session never reads as current.
|
|
577
|
+
const stale = typeof score.lastActiveMs === "number" && score.lastActiveMs > 600000;
|
|
578
|
+
const shownColor = stale ? "idle" : color;
|
|
579
|
+
dot.className = "dot " + shownColor;
|
|
580
|
+
stateDot.className = stale ? "state-dot" : "state-dot " + color;
|
|
494
581
|
hud.classList.remove("s-green", "s-amber", "s-red");
|
|
495
|
-
hud.classList.add("s-" + color);
|
|
496
|
-
main.textContent = stateName + " - " + fmt(score.ctTokens) + " ctx";
|
|
497
|
-
client.textContent =
|
|
582
|
+
if (!stale) hud.classList.add("s-" + color);
|
|
583
|
+
main.textContent = (stale ? "Idle" : stateName) + " - " + fmt(score.ctTokens) + " ctx";
|
|
584
|
+
client.textContent = sessionIdentity(score, state);
|
|
585
|
+
activityEpoch = typeof score.lastActiveMs === "number" ? Date.now() - score.lastActiveMs : null;
|
|
586
|
+
renderUpdated();
|
|
498
587
|
turnEl.textContent = typeof score.turn === "number" && score.turn > 0 ? "turn " + score.turn : "";
|
|
499
|
-
stateWordEl.textContent = stateName;
|
|
588
|
+
stateWordEl.textContent = stale ? "Idle" : stateName;
|
|
500
589
|
fullness.textContent = fullnessLine(sat, score);
|
|
501
|
-
fullness.style.color = tone(color);
|
|
502
|
-
lead.textContent = leadLine(color);
|
|
503
|
-
lead.style.color = tone(color);
|
|
590
|
+
fullness.style.color = stale ? "#6b675d" : tone(color);
|
|
591
|
+
lead.textContent = stale ? "This session is idle - showing its last state" : leadLine(color);
|
|
592
|
+
lead.style.color = stale ? "#6b675d" : tone(color);
|
|
504
593
|
evidence.textContent = evidenceLine(score, oldPct, repeatCount);
|
|
505
594
|
usefulbar.style.width = Math.max(0, 100 - oldPct) + "%";
|
|
506
595
|
oldbar.style.width = oldPct + "%";
|
|
507
596
|
metaEl.textContent = ctSourceLabel(score.ctSource) + " - lower-bound signal - " + edits + " edits";
|
|
508
|
-
pathEl.textContent =
|
|
597
|
+
pathEl.textContent = "";
|
|
598
|
+
currentSrc = score.sourcePath || null;
|
|
599
|
+
if (revealEl) revealEl.style.display = currentSrc ? "inline-block" : "none";
|
|
509
600
|
missing.textContent = formatMissing(state.missing);
|
|
510
601
|
|
|
511
602
|
renderAlert(score, oldPct, repeatCount);
|
|
512
603
|
renderNudge(color, sat);
|
|
513
604
|
|
|
514
|
-
if (renewBtn) renewBtn.style.display =
|
|
605
|
+
if (renewBtn) renewBtn.style.display = "block"; // always available — warm-start any session, even fresh
|
|
515
606
|
prevTurnState = {
|
|
516
607
|
src: score.sourcePath,
|
|
517
608
|
turn: score.turn,
|
|
@@ -530,6 +621,10 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
530
621
|
prevTurnState = null;
|
|
531
622
|
main.textContent = "No active session yet";
|
|
532
623
|
client.textContent = "EchoMem HUD - " + hudVersion;
|
|
624
|
+
activityEpoch = null;
|
|
625
|
+
if (updatedEl) updatedEl.textContent = "";
|
|
626
|
+
currentSrc = null;
|
|
627
|
+
if (revealEl) revealEl.style.display = "none";
|
|
533
628
|
turnEl.textContent = "";
|
|
534
629
|
stateWordEl.textContent = "Fresh";
|
|
535
630
|
fullness.textContent = "";
|
|
@@ -634,6 +729,56 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
634
729
|
return "Not seeing " + list.map(label).join(", ") + " yet";
|
|
635
730
|
}
|
|
636
731
|
|
|
732
|
+
// Subtitle: which session this is (client + project) + version. The "other clients working"
|
|
733
|
+
// signal now lives in the live-spinner tabs, so it's dropped here to keep the line short.
|
|
734
|
+
function sessionIdentity(score) {
|
|
735
|
+
const parts = [label(score.client)];
|
|
736
|
+
if (score.label) parts.push(score.label);
|
|
737
|
+
parts.push(hudVersion);
|
|
738
|
+
return parts.join(" · ");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Recent sessions as switchable tabs. "Auto" clears the pin. Hidden when there's only one session.
|
|
742
|
+
function renderTabs(state, activeId) {
|
|
743
|
+
if (!tabsEl) return;
|
|
744
|
+
const list = (state && state.recentSessions) || [];
|
|
745
|
+
if (list.length <= 1) { tabsEl.innerHTML = ""; return; }
|
|
746
|
+
const chips = list.map((s) => {
|
|
747
|
+
const cls = s.id === activeId ? "tab active" : "tab";
|
|
748
|
+
const dot = s.live ? '<span class="spin"></span>' : "";
|
|
749
|
+
const title = s.title || label(s.client);
|
|
750
|
+
return '<button class="' + cls + '" data-id="' + esc(s.id) + '" title="' + esc(label(s.client) + " · " + title) + '">' + dot + esc(shortTitle(title)) + "</button>";
|
|
751
|
+
});
|
|
752
|
+
chips.unshift('<button class="tab' + (activeId ? "" : " active") + '" data-id="auto">Auto</button>');
|
|
753
|
+
tabsEl.innerHTML = chips.join("");
|
|
754
|
+
}
|
|
755
|
+
function activeIdFor(state, src) {
|
|
756
|
+
const list = (state && state.recentSessions) || [];
|
|
757
|
+
const match = list.filter((s) => s.sourcePath === src)[0];
|
|
758
|
+
return match ? match.id : null;
|
|
759
|
+
}
|
|
760
|
+
function shortTitle(t) {
|
|
761
|
+
t = String(t || "");
|
|
762
|
+
return t.length > 22 ? t.slice(0, 21) + "…" : t;
|
|
763
|
+
}
|
|
764
|
+
function esc(s) {
|
|
765
|
+
return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function renderUpdated() {
|
|
769
|
+
if (!updatedEl) return;
|
|
770
|
+
updatedEl.textContent = activityEpoch === null ? "" : "updated " + relTime(Date.now() - activityEpoch);
|
|
771
|
+
}
|
|
772
|
+
function relTime(ms) {
|
|
773
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
774
|
+
if (s < 5) return "now";
|
|
775
|
+
if (s < 60) return s + "s ago";
|
|
776
|
+
const m = Math.round(s / 60);
|
|
777
|
+
if (m < 60) return m + "m ago";
|
|
778
|
+
return Math.round(m / 60) + "h ago";
|
|
779
|
+
}
|
|
780
|
+
setInterval(renderUpdated, 1000);
|
|
781
|
+
|
|
637
782
|
function label(clientName) {
|
|
638
783
|
if (clientName === "codex") return "Codex";
|
|
639
784
|
if (clientName === "claude-code") return "Claude Code";
|