@echomem/mcp 1.4.6 → 1.4.7

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.
@@ -12,6 +12,7 @@ const COLLAPSED_HEIGHT = 112;
12
12
  const EXPANDED_HEIGHT = 460;
13
13
  let hudServer = null;
14
14
  let mainWindow = null;
15
+ const dragState = new Map();
15
16
  // Single-instance: a second `echomem-hud app` brings the existing HUD back instead of opening another.
16
17
  const gotSingleInstanceLock = app.requestSingleInstanceLock();
17
18
  if (!gotSingleInstanceLock)
@@ -31,6 +32,33 @@ app.whenReady().then(async () => {
31
32
  win.setBounds({ ...bounds, height });
32
33
  writeBounds(win);
33
34
  });
35
+ ipcMain.on("hud:drag-start", (event, payload) => {
36
+ const win = BrowserWindow.fromWebContents(event.sender);
37
+ if (!win)
38
+ return;
39
+ const point = dragPoint(payload);
40
+ if (!point)
41
+ return;
42
+ dragState.set(event.sender.id, { ...point, bounds: win.getBounds() });
43
+ });
44
+ ipcMain.on("hud:drag-move", (event, payload) => {
45
+ const win = BrowserWindow.fromWebContents(event.sender);
46
+ const state = dragState.get(event.sender.id);
47
+ const point = dragPoint(payload);
48
+ if (!win || !state || !point)
49
+ return;
50
+ win.setBounds({
51
+ ...state.bounds,
52
+ x: Math.round(state.bounds.x + point.startX - state.startX),
53
+ y: Math.round(state.bounds.y + point.startY - state.startY),
54
+ });
55
+ });
56
+ ipcMain.on("hud:drag-end", (event) => {
57
+ dragState.delete(event.sender.id);
58
+ const win = BrowserWindow.fromWebContents(event.sender);
59
+ if (win)
60
+ writeBounds(win);
61
+ });
34
62
  const preferredUrl = `http://127.0.0.1:${port}`;
35
63
  const onReveal = (filePath) => {
36
64
  try {
@@ -40,8 +68,16 @@ app.whenReady().then(async () => {
40
68
  /* best-effort */
41
69
  }
42
70
  };
71
+ const onOpenExternal = (externalUrl) => {
72
+ try {
73
+ shell.openExternal(externalUrl);
74
+ }
75
+ catch {
76
+ /* best-effort */
77
+ }
78
+ };
43
79
  try {
44
- hudServer = await createHudServer({ mode, port, onReveal });
80
+ hudServer = await createHudServer({ mode, port, onReveal, onOpenExternal });
45
81
  createWindow(hudServer.url);
46
82
  }
47
83
  catch (error) {
@@ -49,7 +85,7 @@ app.whenReady().then(async () => {
49
85
  createWindow(preferredUrl);
50
86
  return;
51
87
  }
52
- hudServer = await createHudServer({ mode, port: 0, onReveal });
88
+ hudServer = await createHudServer({ mode, port: 0, onReveal, onOpenExternal });
53
89
  createWindow(hudServer.url);
54
90
  }
55
91
  }).catch((error) => {
@@ -90,8 +126,11 @@ function createWindow(url) {
90
126
  }
91
127
  win.on("moved", () => writeBounds(win));
92
128
  mainWindow = win;
93
- win.on("closed", () => { if (mainWindow === win)
94
- mainWindow = null; });
129
+ win.on("closed", () => {
130
+ dragState.delete(win.webContents.id);
131
+ if (mainWindow === win)
132
+ mainWindow = null;
133
+ });
95
134
  win.webContents.on("context-menu", () => {
96
135
  const menu = Menu.buildFromTemplate([
97
136
  { label: "Hide for now", click: () => { if (!win.isDestroyed())
@@ -207,6 +246,11 @@ function writeBounds(win) {
207
246
  fs.mkdirSync(path.dirname(boundsPath()), { recursive: true });
208
247
  fs.writeFileSync(boundsPath(), JSON.stringify(b, null, 2));
209
248
  }
249
+ function dragPoint(payload) {
250
+ const startX = typeof payload.screenX === "number" && Number.isFinite(payload.screenX) ? payload.screenX : null;
251
+ const startY = typeof payload.screenY === "number" && Number.isFinite(payload.screenY) ? payload.screenY : null;
252
+ return startX === null || startY === null ? null : { startX, startY };
253
+ }
210
254
  function parseFlags(argv) {
211
255
  const parsed = {};
212
256
  for (let i = 0; i < argv.length; i += 1) {
@@ -7,4 +7,13 @@ electron_1.contextBridge.exposeInMainWorld("echomemHud", {
7
7
  setOpen(open, height) {
8
8
  electron_1.ipcRenderer.send("hud:set-open", { open, height });
9
9
  },
10
+ startDrag(screenX, screenY) {
11
+ electron_1.ipcRenderer.send("hud:drag-start", { screenX, screenY });
12
+ },
13
+ moveDrag(screenX, screenY) {
14
+ electron_1.ipcRenderer.send("hud:drag-move", { screenX, screenY });
15
+ },
16
+ endDrag() {
17
+ electron_1.ipcRenderer.send("hud:drag-end");
18
+ },
10
19
  });
@@ -1,9 +1,12 @@
1
1
  import http from "node:http";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import { HudMonitor } from "./monitor.js";
5
7
  import { HUD_HTML } from "./web.js";
6
8
  import { buildCapsuleText } from "./capsule.js";
9
+ import { homePath } from "./fs.js";
7
10
  export async function createHudServer(opts = {}) {
8
11
  const mode = opts.mode || "auto";
9
12
  const port = opts.port ?? 17377;
@@ -47,6 +50,96 @@ export async function createHudServer(opts = {}) {
47
50
  res.end(JSON.stringify({ ok: true, pinned: id && id !== "auto" ? id : null }));
48
51
  return;
49
52
  }
53
+ if (url.pathname === "/viewer") {
54
+ const viewerPath = fileURLToPath(new URL("../../assets/hud/session-viewer.html", import.meta.url));
55
+ if (!fs.existsSync(viewerPath)) {
56
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
57
+ res.end("session viewer not found");
58
+ return;
59
+ }
60
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
61
+ fs.createReadStream(viewerPath).pipe(res);
62
+ return;
63
+ }
64
+ if (url.pathname === "/session-raw") {
65
+ // Serve a tracked session's raw log so the viewer can fetch it same-origin. Path-validated.
66
+ const target = url.searchParams.get("path") || "";
67
+ const known = new Set([
68
+ ...(latest.recentSessions || []).map((s) => s.sourcePath),
69
+ ...(latest.sessions || []).map((s) => s.sourcePath),
70
+ ]);
71
+ if (!target || !known.has(target) || !fs.existsSync(target)) {
72
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
73
+ res.end("not a tracked session");
74
+ return;
75
+ }
76
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
77
+ fs.createReadStream(target).pipe(res);
78
+ return;
79
+ }
80
+ if (url.pathname === "/session-payload") {
81
+ // The REAL context window: Codex logs each API request (websocket request:) in logs_2.sqlite.
82
+ // Return one payload per line (NDJSON) so the same viewer can render them like records.
83
+ const target = url.searchParams.get("path") || "";
84
+ const known = new Set([
85
+ ...(latest.recentSessions || []).map((s) => s.sourcePath),
86
+ ...(latest.sessions || []).map((s) => s.sourcePath),
87
+ ]);
88
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
89
+ if (!target || !known.has(target)) {
90
+ res.end(JSON.stringify({ note: "not a tracked session" }));
91
+ return;
92
+ }
93
+ // Real request payloads are only logged by Codex (logs_2.sqlite). Claude sessions have none.
94
+ if (!path.basename(target).startsWith("rollout-")) {
95
+ res.end(JSON.stringify({ note: "This is a Claude session — the real request payload (context window) is only recorded for Codex. Switch to a Codex tab in the HUD, then open the viewer." }));
96
+ return;
97
+ }
98
+ const uuid = (path.basename(target).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/) || [])[0] || "";
99
+ const db = homePath(".codex", "logs_2.sqlite");
100
+ if (!uuid || !fs.existsSync(db)) {
101
+ res.end(JSON.stringify({ note: "no Codex request-payload log found (logs_2.sqlite missing or no thread id)" }));
102
+ return;
103
+ }
104
+ try {
105
+ const query = `SELECT feedback_log_body FROM logs WHERE thread_id='${uuid}' AND feedback_log_body LIKE '%websocket request: %' ORDER BY ts LIMIT 500;`;
106
+ const out = execFileSync("sqlite3", ["-json", db, query], { maxBuffer: 256 * 1024 * 1024 }).toString();
107
+ const rows = JSON.parse(out || "[]");
108
+ const lines = [];
109
+ for (const row of rows) {
110
+ const body = row.feedback_log_body || "";
111
+ const at = body.indexOf("websocket request: ");
112
+ if (at < 0)
113
+ continue;
114
+ try {
115
+ lines.push(JSON.stringify(JSON.parse(body.slice(at + "websocket request: ".length))));
116
+ }
117
+ catch {
118
+ /* skip unparseable payload */
119
+ }
120
+ }
121
+ res.end(lines.length ? lines.join("\n") : JSON.stringify({ note: "no request payloads found for this thread" }));
122
+ }
123
+ catch (error) {
124
+ res.end(JSON.stringify({ note: "payload query failed", error: error instanceof Error ? error.message : String(error) }));
125
+ }
126
+ return;
127
+ }
128
+ if (url.pathname === "/open-viewer") {
129
+ const target = url.searchParams.get("path") || "";
130
+ const known = new Set([
131
+ ...(latest.recentSessions || []).map((s) => s.sourcePath),
132
+ ...(latest.sessions || []).map((s) => s.sourcePath),
133
+ ]);
134
+ const canOpen = Boolean(target) && known.has(target) && typeof opts.onOpenExternal === "function";
135
+ if (canOpen) {
136
+ const host = req.headers.host || `127.0.0.1:${port}`;
137
+ opts.onOpenExternal(`http://${host}/viewer?path=${encodeURIComponent(target)}`);
138
+ }
139
+ res.writeHead(200, { "content-type": "application/json" });
140
+ res.end(JSON.stringify({ ok: canOpen, reason: canOpen ? undefined : opts.onOpenExternal ? "unknown_path" : "no_desktop" }));
141
+ return;
142
+ }
50
143
  if (url.pathname === "/reveal") {
51
144
  const target = url.searchParams.get("path") || "";
52
145
  // Only reveal paths we actually track — never open an arbitrary path from a query string.
package/dist/hud/web.js CHANGED
@@ -37,12 +37,14 @@ export const HUD_HTML = String.raw `<!doctype html>
37
37
  width: min(360px, calc(100vw - 16px));
38
38
  background: transparent;
39
39
  -webkit-font-smoothing: antialiased;
40
+ cursor: grab;
40
41
  }
42
+ #hud.dragging { cursor: grabbing; }
41
43
  .glance {
42
44
  position: relative;
43
45
  height: 112px;
44
46
  user-select: none;
45
- cursor: default;
47
+ cursor: grab;
46
48
  }
47
49
  .drag {
48
50
  position: absolute;
@@ -51,8 +53,8 @@ export const HUD_HTML = String.raw `<!doctype html>
51
53
  width: 20px;
52
54
  height: 32px;
53
55
  border-radius: 8px;
54
- -webkit-app-region: drag;
55
56
  opacity: 0.78;
57
+ pointer-events: none;
56
58
  z-index: 5;
57
59
  }
58
60
  .drag::before {
@@ -75,7 +77,7 @@ export const HUD_HTML = String.raw `<!doctype html>
75
77
  height: 70px;
76
78
  overflow: hidden;
77
79
  border-radius: 18px;
78
- transition: filter 0.45s ease;
80
+ transition: filter 0.45s ease, transform 0.18s ease;
79
81
  }
80
82
  @keyframes echo-bob {
81
83
  0%, 100% { transform: translateY(0); }
@@ -106,6 +108,7 @@ export const HUD_HTML = String.raw `<!doctype html>
106
108
  border-radius: 34px;
107
109
  background: #fdfaf2;
108
110
  filter: var(--shadow);
111
+ transition: border-color 0.18s ease, background 0.18s ease, filter 0.18s ease;
109
112
  }
110
113
  .status-bubble::after {
111
114
  content: "";
@@ -177,6 +180,7 @@ export const HUD_HTML = String.raw `<!doctype html>
177
180
  background: var(--card);
178
181
  color: var(--ink);
179
182
  filter: var(--shadow);
183
+ cursor: grab;
180
184
  }
181
185
  #hud.open .details { display: block; }
182
186
  .head {
@@ -387,6 +391,29 @@ export const HUD_HTML = String.raw `<!doctype html>
387
391
  #hud.s-red .mascot-stage { filter: drop-shadow(0 3px 8px rgba(199, 55, 47, 0.48)); }
388
392
  #hud.s-amber .mascot { animation-duration: 5.6s; }
389
393
  #hud.s-red .mascot { animation-duration: 6.8s; }
394
+ #hud:hover .status-bubble {
395
+ border-color: rgba(26, 58, 143, 0.96);
396
+ background: #fff7e8;
397
+ filter: drop-shadow(0 3px 10px rgba(26, 58, 143, 0.18));
398
+ }
399
+ #hud:hover .drag::before {
400
+ background: rgba(26, 58, 143, 0.72);
401
+ box-shadow: 0 10px 0 rgba(26, 58, 143, 0.72), 10px 0 0 rgba(26, 58, 143, 0.72), 10px 10px 0 rgba(26, 58, 143, 0.72);
402
+ }
403
+ #hud:hover .mascot-stage { transform: translateY(-1px); }
404
+ #hud.dragging .glance,
405
+ #hud.dragging .details,
406
+ #hud.dragging button { cursor: grabbing; }
407
+ #hud.dragging .status-bubble {
408
+ border-color: rgba(199, 55, 47, 0.86);
409
+ background: #fff1df;
410
+ filter: drop-shadow(0 4px 12px rgba(199, 55, 47, 0.22));
411
+ }
412
+ #hud.dragging .drag::before {
413
+ background: rgba(199, 55, 47, 0.92);
414
+ box-shadow: 0 10px 0 rgba(199, 55, 47, 0.92), 10px 0 0 rgba(199, 55, 47, 0.92), 10px 10px 0 rgba(199, 55, 47, 0.92);
415
+ }
416
+ #hud.dragging .mascot-stage { transform: translateY(-2px) scale(1.02); }
390
417
  </style>
391
418
  </head>
392
419
  <body>
@@ -429,7 +456,7 @@ export const HUD_HTML = String.raw `<!doctype html>
429
456
  </div>
430
457
  <div class="meta" id="meta"></div>
431
458
  <div class="path" id="path"></div>
432
- <button class="reveal" id="reveal" style="display:none">↗ Reveal session log in Finder</button>
459
+ <button class="reveal" id="reveal" style="display:none">↗ Open session in viewer</button>
433
460
  <div class="missing" id="missing"></div>
434
461
  </div>
435
462
  </section>
@@ -468,11 +495,53 @@ export const HUD_HTML = String.raw `<!doctype html>
468
495
  let activityEpoch = null;
469
496
  let lastState = null;
470
497
  let currentSrc = null;
498
+ let hudDrag = null;
499
+ let suppressNextToggle = false;
500
+ const dragBlockSelector = "button, input, textarea, select, a, pre, .tab, .switchbtn, .reveal, .renew-btn, .copy-btn";
501
+
502
+ hud.addEventListener("pointerdown", beginHudDrag);
503
+ window.addEventListener("pointermove", moveHudDrag);
504
+ window.addEventListener("pointerup", endHudDrag);
505
+ window.addEventListener("pointercancel", endHudDrag);
506
+
507
+ function beginHudDrag(event) {
508
+ if (event.button !== 0 || !(event.target instanceof HTMLElement)) return;
509
+ if (event.target.closest(dragBlockSelector)) return;
510
+ if (!window.echomemHud || typeof window.echomemHud.startDrag !== "function") return;
511
+ hudDrag = { pointerId: event.pointerId, startX: event.screenX, startY: event.screenY, moved: false };
512
+ try { hud.setPointerCapture(event.pointerId); } catch {}
513
+ window.echomemHud.startDrag(event.screenX, event.screenY);
514
+ }
515
+
516
+ function moveHudDrag(event) {
517
+ if (!hudDrag || event.pointerId !== hudDrag.pointerId) return;
518
+ if (!window.echomemHud || typeof window.echomemHud.moveDrag !== "function") return;
519
+ const dx = event.screenX - hudDrag.startX;
520
+ const dy = event.screenY - hudDrag.startY;
521
+ if (!hudDrag.moved && Math.hypot(dx, dy) < 3) return;
522
+ hudDrag.moved = true;
523
+ hud.classList.add("dragging");
524
+ window.echomemHud.moveDrag(event.screenX, event.screenY);
525
+ event.preventDefault();
526
+ }
527
+
528
+ function endHudDrag(event) {
529
+ if (!hudDrag || event.pointerId !== hudDrag.pointerId) return;
530
+ const moved = hudDrag.moved;
531
+ hudDrag = null;
532
+ hud.classList.remove("dragging");
533
+ try { hud.releasePointerCapture(event.pointerId); } catch {}
534
+ if (window.echomemHud && typeof window.echomemHud.endDrag === "function") window.echomemHud.endDrag();
535
+ if (moved) {
536
+ suppressNextToggle = true;
537
+ setTimeout(() => { suppressNextToggle = false; }, 180);
538
+ }
539
+ }
471
540
 
472
- // Reveal the active session's log file in Finder (Electron only; no-op in browser mode).
541
+ // Open the active session in the session viewer (Electron opens the browser; no-op otherwise).
473
542
  if (revealEl) revealEl.addEventListener("click", () => {
474
543
  if (!currentSrc) return;
475
- fetch("/reveal?path=" + encodeURIComponent(currentSrc)).catch(() => {});
544
+ fetch("/open-viewer?path=" + encodeURIComponent(currentSrc)).catch(() => {});
476
545
  });
477
546
 
478
547
  // Circular top-right toggle: jump to the next agent's newest session (Claude ⇄ Codex).
@@ -507,7 +576,13 @@ export const HUD_HTML = String.raw `<!doctype html>
507
576
  });
508
577
 
509
578
  document.getElementById("toggle").addEventListener("click", (event) => {
510
- if (event.target instanceof HTMLElement && (event.target.closest(".drag") || event.target.closest(".switchbtn"))) return;
579
+ if (suppressNextToggle) {
580
+ suppressNextToggle = false;
581
+ event.preventDefault();
582
+ event.stopPropagation();
583
+ return;
584
+ }
585
+ if (event.target instanceof HTMLElement && event.target.closest(".switchbtn")) return;
511
586
  hud.classList.toggle("open");
512
587
  syncHeight();
513
588
  });