@echomem/mcp 1.4.4 → 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.
@@ -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) => {
@@ -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.refreshThreadCounts();
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: sessions[0] || null,
70
- scores: sessions,
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
- // Count each client's "ongoing" threads = session files written within ONGOING_WINDOW_MS. Throttled,
107
- // since it walks every session file per client (cheap at a few-second cadence, wasteful at 750ms).
108
- refreshThreadCounts() {
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 && Object.keys(this.threadCounts).length)
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
- if (now - fs.statSync(file).mtimeMs < ONGOING_WINDOW_MS)
119
- n += 1;
170
+ mtimeMs = fs.statSync(file).mtimeMs;
120
171
  }
121
172
  catch {
122
- /* ignore files that vanish mid-walk */
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";
@@ -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 22px 18px 30px;
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: flex-end;
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,23 +572,31 @@ 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
- dot.className = "dot " + color;
493
- stateDot.className = "state-dot " + color;
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 = label(score.client) + " - " + hudVersion;
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 = score.sourcePath ? "Reading " + score.sourcePath : "";
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);
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "description": "EchoMem Cloud-First MCP Server",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",