@echomem/mcp 1.4.6 → 1.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ // Show-after-restart for the HUD via a macOS LaunchAgent. We ship a CLI, not an .app bundle, so this is
6
+ // the standard way to make the overlay persist across quit/reboot. Shared by the CLI (`echomem-hud
7
+ // autostart`) and the Electron context menu so both write an identical, robust registration.
8
+ const LABEL = "com.echomem.hud";
9
+ export function autostartPlistPath() {
10
+ return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
11
+ }
12
+ export function autostartSupported() {
13
+ return process.platform === "darwin";
14
+ }
15
+ export function isAutostartEnabled() {
16
+ return autostartSupported() && fs.existsSync(autostartPlistPath());
17
+ }
18
+ function escapeXml(value) {
19
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
20
+ }
21
+ function launchctlUid() {
22
+ return typeof process.getuid === "function" ? process.getuid() : 501;
23
+ }
24
+ /**
25
+ * Register the LaunchAgent. `programArgs` is the full ProgramArguments array — the caller supplies the
26
+ * exact launch command (electron binary + electron-main.js + flags) so it works regardless of whether
27
+ * node is on launchd's PATH. Loads immediately as well as at future logins.
28
+ */
29
+ export function enableAutostart(programArgs) {
30
+ if (!autostartSupported())
31
+ return { ok: false, message: "autostart is currently macOS-only." };
32
+ const plist = autostartPlistPath();
33
+ const argsXml = programArgs.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
34
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
35
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
36
+ <plist version="1.0"><dict>
37
+ <key>Label</key><string>${LABEL}</string>
38
+ <key>ProgramArguments</key><array>
39
+ ${argsXml}
40
+ </array>
41
+ <key>RunAtLoad</key><true/>
42
+ <key>StandardOutPath</key><string>/tmp/echomem-hud.log</string>
43
+ <key>StandardErrorPath</key><string>/tmp/echomem-hud.log</string>
44
+ </dict></plist>
45
+ `;
46
+ fs.mkdirSync(path.dirname(plist), { recursive: true });
47
+ fs.writeFileSync(plist, xml);
48
+ const uid = launchctlUid();
49
+ spawnSync("launchctl", ["bootout", `gui/${uid}/${LABEL}`], { stdio: "ignore" }); // replace stale reg
50
+ const boot = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, plist], { encoding: "utf8" });
51
+ return boot.status === 0
52
+ ? { ok: true, message: `EchoMem HUD will show after restart. Off anytime: echomem-hud autostart off` }
53
+ : { ok: true, message: `Wrote ${plist} — shows after your next restart.` };
54
+ }
55
+ export function disableAutostart() {
56
+ if (!autostartSupported())
57
+ return;
58
+ const uid = launchctlUid();
59
+ spawnSync("launchctl", ["bootout", `gui/${uid}/${LABEL}`], { stdio: "ignore" });
60
+ try {
61
+ fs.rmSync(autostartPlistPath(), { force: true });
62
+ }
63
+ catch {
64
+ /* already gone */
65
+ }
66
+ }
package/dist/hud/cli.js CHANGED
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { createRequire } from "node:module";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { adapterList } from "./adapters.js";
6
+ import { autostartPlistPath, autostartSupported, disableAutostart, enableAutostart, isAutostartEnabled } from "./autostart.js";
6
7
  import { statSignature } from "./fs.js";
7
8
  import { installHooks } from "./hooks.js";
8
9
  import { HudMonitor } from "./monitor.js";
@@ -23,6 +24,8 @@ try {
23
24
  await cmdApp(flags);
24
25
  else if (command === "install-hooks")
25
26
  await cmdInstallHooks(flags);
27
+ else if (command === "autostart")
28
+ cmdAutostart(argv[1] || "status", flags);
26
29
  else if (command === "status")
27
30
  await cmdStatus(flags);
28
31
  else if (command === "report")
@@ -79,6 +82,33 @@ async function cmdApp(flags) {
79
82
  child.unref();
80
83
  console.log("EchoMem HUD app launched.");
81
84
  }
85
+ /**
86
+ * `echomem-hud autostart on|off|status` — show the HUD after restart via a macOS LaunchAgent, so
87
+ * closing/rebooting doesn't lose it (we ship a CLI, not an .app, so this is the standard mechanism).
88
+ * The registration runs the electron binary directly on electron-main.js — no dependency on node
89
+ * being on launchd's PATH — matching what the in-app "Show after restart" toggle writes.
90
+ */
91
+ function cmdAutostart(action, flags) {
92
+ if (!autostartSupported()) {
93
+ console.log("autostart is currently macOS-only (LaunchAgent).");
94
+ return;
95
+ }
96
+ if (action === "on") {
97
+ const require = createRequire(import.meta.url);
98
+ const electronPath = require("electron");
99
+ const mainPath = fileURLToPath(new URL("./electron-main.js", import.meta.url));
100
+ const client = typeof flags.client === "string" ? flags.client : "auto";
101
+ const result = enableAutostart([electronPath, mainPath, "--client", client]);
102
+ console.log(`✅ ${result.message}`);
103
+ return;
104
+ }
105
+ if (action === "off") {
106
+ disableAutostart();
107
+ console.log("✅ Show after restart disabled — the HUD will not relaunch after restart.");
108
+ return;
109
+ }
110
+ console.log(isAutostartEnabled() ? `Autostart: ON (${autostartPlistPath()})` : "Autostart: OFF. Enable with: echomem-hud autostart on");
111
+ }
82
112
  async function cmdInstallHooks(flags) {
83
113
  const paths = installHooks(parseMode(flags.client));
84
114
  console.log(`Installed EchoMem HUD hook support:\n${paths.map((p) => `- ${p}`).join("\n")}`);
@@ -136,6 +166,7 @@ Usage:
136
166
  echomem-hud serve [--client codex|claude-code|claude-desktop|both|auto] [--port 17377]
137
167
  echomem-hud app [--client codex|claude-code|claude-desktop|both|auto]
138
168
  echomem-hud install-hooks [--client codex|claude-code|both]
169
+ echomem-hud autostart on|off|status (show the HUD after restart — macOS)
139
170
  echomem-hud status
140
171
  echomem-hud report [--client codex|claude-code|claude-desktop|auto] [--limit 40] [--json]
141
172
  `);
@@ -1,8 +1,10 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
- import { app, BrowserWindow, ipcMain, Menu, screen, shell } from "electron";
6
+ import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, screen, shell } from "electron";
7
+ import { disableAutostart, enableAutostart, isAutostartEnabled } from "./autostart.js";
6
8
  import { createHudServer } from "./server.js";
7
9
  const flags = parseFlags(process.argv.slice(2));
8
10
  const mode = parseMode(flags.client);
@@ -10,8 +12,17 @@ const port = typeof flags.port === "string" ? Number(flags.port) || 17377 : 1737
10
12
  const COLLAPSED_WIDTH = 360;
11
13
  const COLLAPSED_HEIGHT = 112;
12
14
  const EXPANDED_HEIGHT = 460;
15
+ // Mini mode: a small always-on pill (status dot + tokens) for users who find the full bubble too big.
16
+ const MINI_WIDTH = 190;
17
+ const MINI_HEIGHT = 48;
18
+ const AGENT_VISIBILITY_POLL_MS = 900;
19
+ let miniPref = false;
20
+ let onlyShowWhenAgentOpen = false;
21
+ let manuallyHidden = false;
13
22
  let hudServer = null;
14
23
  let mainWindow = null;
24
+ let visibilityTimer = null;
25
+ const dragState = new Map();
15
26
  // Single-instance: a second `echomem-hud app` brings the existing HUD back instead of opening another.
16
27
  const gotSingleInstanceLock = app.requestSingleInstanceLock();
17
28
  if (!gotSingleInstanceLock)
@@ -27,10 +38,52 @@ app.whenReady().then(async () => {
27
38
  if (!win)
28
39
  return;
29
40
  const bounds = win.getBounds();
30
- const height = open ? clampExpandedHeight(measured && measured > 200 ? measured : EXPANDED_HEIGHT) : COLLAPSED_HEIGHT;
31
- win.setBounds({ ...bounds, height });
41
+ // Closing returns to the user's preferred resting size (mini pill or the full bubble).
42
+ const width = open ? COLLAPSED_WIDTH : miniPref ? MINI_WIDTH : COLLAPSED_WIDTH;
43
+ const height = open ? clampExpandedHeight(measured && measured > 200 ? measured : EXPANDED_HEIGHT) : miniPref ? MINI_HEIGHT : COLLAPSED_HEIGHT;
44
+ win.setBounds({ ...bounds, width, height });
32
45
  writeBounds(win);
33
46
  });
47
+ ipcMain.on("hud:set-mini", (event, payload) => {
48
+ miniPref = typeof payload === "object" ? Boolean(payload.mini) : Boolean(payload);
49
+ const win = BrowserWindow.fromWebContents(event.sender);
50
+ if (!win)
51
+ return;
52
+ const bounds = win.getBounds();
53
+ win.setBounds({
54
+ ...bounds,
55
+ width: miniPref ? MINI_WIDTH : COLLAPSED_WIDTH,
56
+ height: miniPref ? MINI_HEIGHT : COLLAPSED_HEIGHT,
57
+ });
58
+ writeBounds(win);
59
+ });
60
+ ipcMain.on("hud:drag-start", (event, payload) => {
61
+ const win = BrowserWindow.fromWebContents(event.sender);
62
+ if (!win)
63
+ return;
64
+ const point = dragPoint(payload);
65
+ if (!point)
66
+ return;
67
+ dragState.set(event.sender.id, { ...point, bounds: win.getBounds() });
68
+ });
69
+ ipcMain.on("hud:drag-move", (event, payload) => {
70
+ const win = BrowserWindow.fromWebContents(event.sender);
71
+ const state = dragState.get(event.sender.id);
72
+ const point = dragPoint(payload);
73
+ if (!win || !state || !point)
74
+ return;
75
+ win.setBounds({
76
+ ...state.bounds,
77
+ x: Math.round(state.bounds.x + point.startX - state.startX),
78
+ y: Math.round(state.bounds.y + point.startY - state.startY),
79
+ });
80
+ });
81
+ ipcMain.on("hud:drag-end", (event) => {
82
+ dragState.delete(event.sender.id);
83
+ const win = BrowserWindow.fromWebContents(event.sender);
84
+ if (win)
85
+ writeBounds(win);
86
+ });
34
87
  const preferredUrl = `http://127.0.0.1:${port}`;
35
88
  const onReveal = (filePath) => {
36
89
  try {
@@ -40,8 +93,16 @@ app.whenReady().then(async () => {
40
93
  /* best-effort */
41
94
  }
42
95
  };
96
+ const onOpenExternal = (externalUrl) => {
97
+ try {
98
+ shell.openExternal(externalUrl);
99
+ }
100
+ catch {
101
+ /* best-effort */
102
+ }
103
+ };
43
104
  try {
44
- hudServer = await createHudServer({ mode, port, onReveal });
105
+ hudServer = await createHudServer({ mode, port, onReveal, onOpenExternal });
45
106
  createWindow(hudServer.url);
46
107
  }
47
108
  catch (error) {
@@ -49,7 +110,7 @@ app.whenReady().then(async () => {
49
110
  createWindow(preferredUrl);
50
111
  return;
51
112
  }
52
- hudServer = await createHudServer({ mode, port: 0, onReveal });
113
+ hudServer = await createHudServer({ mode, port: 0, onReveal, onOpenExternal });
53
114
  createWindow(hudServer.url);
54
115
  }
55
116
  }).catch((error) => {
@@ -63,10 +124,12 @@ app.on("window-all-closed", async () => {
63
124
  });
64
125
  function createWindow(url) {
65
126
  const savedBounds = readBounds();
127
+ miniPref = Boolean(savedBounds?.mini);
128
+ onlyShowWhenAgentOpen = Boolean(savedBounds?.onlyShowWhenAgentOpen);
66
129
  const bounds = savedBounds ? normalizedBounds(savedBounds) : defaultBounds();
67
130
  const win = new BrowserWindow({
68
- width: COLLAPSED_WIDTH,
69
- height: COLLAPSED_HEIGHT,
131
+ width: miniPref ? MINI_WIDTH : COLLAPSED_WIDTH,
132
+ height: miniPref ? MINI_HEIGHT : COLLAPSED_HEIGHT,
70
133
  x: bounds.x,
71
134
  y: bounds.y,
72
135
  frame: false,
@@ -90,14 +153,30 @@ function createWindow(url) {
90
153
  }
91
154
  win.on("moved", () => writeBounds(win));
92
155
  mainWindow = win;
93
- win.on("closed", () => { if (mainWindow === win)
94
- mainWindow = null; });
156
+ const webContentsId = win.webContents.id;
157
+ win.on("closed", () => {
158
+ dragState.delete(webContentsId);
159
+ stopAgentVisibilityLoop();
160
+ if (mainWindow === win)
161
+ mainWindow = null;
162
+ });
95
163
  win.webContents.on("context-menu", () => {
96
164
  const menu = Menu.buildFromTemplate([
97
- { label: "Hide for now", click: () => { if (!win.isDestroyed())
98
- win.hide(); } },
165
+ { label: "Hide for now", click: () => { hideForNow(win); } },
166
+ {
167
+ label: "Only show when Codex/Claude is open",
168
+ type: "checkbox",
169
+ checked: onlyShowWhenAgentOpen,
170
+ click: (item) => { setOnlyShowWhenAgentOpen(item.checked, win); },
171
+ },
172
+ {
173
+ label: "Show after restart",
174
+ type: "checkbox",
175
+ checked: isAutostartEnabled(),
176
+ click: (item) => { setAutostart(item.checked); },
177
+ },
99
178
  { type: "separator" },
100
- { label: "Quit EchoMem HUD", click: () => app.quit() },
179
+ { label: "Quit EchoMem HUD", click: () => quitWithAutostartPrompt(win) },
101
180
  ]);
102
181
  menu.popup({ window: win });
103
182
  });
@@ -106,10 +185,11 @@ function createWindow(url) {
106
185
  if (shown || win.isDestroyed())
107
186
  return;
108
187
  shown = true;
109
- app.focus({ steal: true });
110
- win.show();
111
- win.moveTop();
112
- win.focus();
188
+ if (onlyShowWhenAgentOpen)
189
+ syncAgentVisibility(win);
190
+ else
191
+ showHudWindow(win, true);
192
+ startAgentVisibilityLoop(win);
113
193
  writeBounds(win);
114
194
  };
115
195
  win.once("ready-to-show", reveal);
@@ -119,6 +199,31 @@ function createWindow(url) {
119
199
  win.webContents.openDevTools({ mode: "detach" });
120
200
  win.loadURL(url);
121
201
  }
202
+ // Show-after-restart registration that runs THIS electron binary on electron-main.js directly — no node
203
+ // PATH dependency. Mirrors what `echomem-hud autostart on` writes, so CLI and menu stay in sync.
204
+ function setAutostart(on) {
205
+ if (on)
206
+ enableAutostart([process.execPath, fileURLPath("./electron-main.js"), "--client", String(mode), "--port", String(port)]);
207
+ else
208
+ disableAutostart();
209
+ }
210
+ // Quitting is the moment the HUD truly disappears (unlike "Hide for now"), so if it won't come back on
211
+ // its own, that's exactly when to offer show-after-restart — per the user's "ask me when it goes away".
212
+ function quitWithAutostartPrompt(win) {
213
+ if (process.platform !== "darwin" || isAutostartEnabled())
214
+ return void app.quit();
215
+ const choice = showRecoveryPrompt({
216
+ buttons: ["Show after restart & quit", "Just quit", "Cancel"],
217
+ cancelId: 2,
218
+ message: "Bring EchoMem back automatically?",
219
+ detail: "EchoMem has no app icon to relaunch from. Turn on Show after restart and it will be waiting after your computer restarts. You can also tell your coding agent to run `echomem-hud app`.",
220
+ });
221
+ if (choice === 2)
222
+ return;
223
+ if (choice === 0)
224
+ setAutostart(true);
225
+ app.quit();
226
+ }
122
227
  function clampExpandedHeight(value) {
123
228
  const workArea = screen.getPrimaryDisplay().workArea;
124
229
  const max = Math.max(COLLAPSED_HEIGHT, workArea.height - 48);
@@ -127,11 +232,11 @@ function clampExpandedHeight(value) {
127
232
  function showExistingWindow() {
128
233
  if (!mainWindow || mainWindow.isDestroyed())
129
234
  return;
130
- if (!mainWindow.isVisible())
131
- mainWindow.show();
132
- mainWindow.moveTop();
133
- app.focus({ steal: true });
134
- mainWindow.focus();
235
+ manuallyHidden = false;
236
+ if (onlyShowWhenAgentOpen)
237
+ syncAgentVisibility(mainWindow);
238
+ else
239
+ showHudWindow(mainWindow, true);
135
240
  }
136
241
  async function hasCompatibleHudState(baseUrl, requestedMode) {
137
242
  const signal = AbortSignal.timeout(750);
@@ -205,7 +310,109 @@ function clamp(value, min, max) {
205
310
  function writeBounds(win) {
206
311
  const b = win.getBounds();
207
312
  fs.mkdirSync(path.dirname(boundsPath()), { recursive: true });
208
- fs.writeFileSync(boundsPath(), JSON.stringify(b, null, 2));
313
+ fs.writeFileSync(boundsPath(), JSON.stringify({ ...b, mini: miniPref, onlyShowWhenAgentOpen }, null, 2));
314
+ }
315
+ function hideForNow(win) {
316
+ if (win.isDestroyed())
317
+ return;
318
+ if (process.platform === "darwin" && !isAutostartEnabled()) {
319
+ const choice = showRecoveryPrompt({
320
+ buttons: ["Show after restart & hide", "Hide for now", "Cancel"],
321
+ cancelId: 2,
322
+ message: "Hide EchoMem for now?",
323
+ detail: "It will stay hidden until you restart it. Turn on Show after restart to bring it back after your computer restarts. Before then, you can ask your coding agent to run `echomem-hud app`.",
324
+ });
325
+ if (choice === 2)
326
+ return;
327
+ if (choice === 0)
328
+ setAutostart(true);
329
+ }
330
+ manuallyHidden = true;
331
+ win.hide();
332
+ }
333
+ function showRecoveryPrompt(options) {
334
+ app.focus({ steal: true });
335
+ const cancelId = typeof options.cancelId === "number" ? options.cancelId : options.buttons.length - 1;
336
+ const { cancelId: _cancelId, ...dialogOptions } = options;
337
+ return dialog.showMessageBoxSync({
338
+ type: "question",
339
+ defaultId: 0,
340
+ cancelId,
341
+ icon: nativeImage.createFromPath(fileURLPath("../../assets/hud/echo-face-cutout.png")),
342
+ ...dialogOptions,
343
+ });
344
+ }
345
+ function setOnlyShowWhenAgentOpen(next, win) {
346
+ onlyShowWhenAgentOpen = next;
347
+ manuallyHidden = false;
348
+ writeBounds(win);
349
+ if (next)
350
+ syncAgentVisibility(win);
351
+ else
352
+ showHudWindow(win, true);
353
+ }
354
+ function startAgentVisibilityLoop(win) {
355
+ stopAgentVisibilityLoop();
356
+ visibilityTimer = setInterval(() => syncAgentVisibility(win), AGENT_VISIBILITY_POLL_MS);
357
+ }
358
+ function stopAgentVisibilityLoop() {
359
+ if (!visibilityTimer)
360
+ return;
361
+ clearInterval(visibilityTimer);
362
+ visibilityTimer = null;
363
+ }
364
+ function syncAgentVisibility(win) {
365
+ if (win.isDestroyed() || !onlyShowWhenAgentOpen)
366
+ return;
367
+ const frontmost = frontmostAppKind();
368
+ if (frontmost === "agent") {
369
+ if (!manuallyHidden && !win.isVisible())
370
+ showHudWindow(win, false);
371
+ else if (!manuallyHidden)
372
+ win.moveTop();
373
+ return;
374
+ }
375
+ // Leave the HUD alone while the user is interacting with the HUD/menu itself, or when macOS does
376
+ // not report a frontmost process. The next concrete app detection will settle visibility.
377
+ if (frontmost === "self" || frontmost === "unknown")
378
+ return;
379
+ if (win.isVisible())
380
+ win.hide();
381
+ }
382
+ function showHudWindow(win, focus) {
383
+ if (win.isDestroyed())
384
+ return;
385
+ if (focus) {
386
+ app.focus({ steal: true });
387
+ win.show();
388
+ win.focus();
389
+ }
390
+ else {
391
+ win.showInactive();
392
+ }
393
+ win.moveTop();
394
+ }
395
+ function frontmostAppKind() {
396
+ if (process.platform !== "darwin")
397
+ return "unknown";
398
+ try {
399
+ const name = execFileSync("osascript", ["-e", 'tell application "System Events" to get name of first application process whose frontmost is true'], { encoding: "utf8", timeout: 500, stdio: ["ignore", "pipe", "ignore"] }).trim().toLowerCase();
400
+ if (!name)
401
+ return "unknown";
402
+ if (name.includes("codex") || name.includes("claude"))
403
+ return "agent";
404
+ if (name.includes("echomem") || name.includes("electron") || name === app.getName().toLowerCase())
405
+ return "self";
406
+ return "other";
407
+ }
408
+ catch {
409
+ return "unknown";
410
+ }
411
+ }
412
+ function dragPoint(payload) {
413
+ const startX = typeof payload.screenX === "number" && Number.isFinite(payload.screenX) ? payload.screenX : null;
414
+ const startY = typeof payload.screenY === "number" && Number.isFinite(payload.screenY) ? payload.screenY : null;
415
+ return startX === null || startY === null ? null : { startX, startY };
209
416
  }
210
417
  function parseFlags(argv) {
211
418
  const parsed = {};
@@ -6,7 +6,6 @@ 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
- const FOCUS_GRACE_MS = 8_000; // keep the last-known frontmost client this long when detection momentarily misses
10
9
  const RECENT_WINDOW_MS = 12 * 60 * 60 * 1000; // a session is a "recent tab" if its log was touched in the last 12h
11
10
  const RECENT_CAP = 16; // most-recent N sessions kept as switchable tabs
12
11
  export class HudMonitor extends EventEmitter {
@@ -19,7 +18,6 @@ export class HudMonitor extends EventEmitter {
19
18
  labelCache = new Map();
20
19
  missing = [];
21
20
  frontmostCheckedAt = 0;
22
- frontmostSeenAt = 0;
23
21
  frontmostClient = null;
24
22
  lastActiveClient = null;
25
23
  threadCounts = {};
@@ -65,17 +63,13 @@ export class HudMonitor extends EventEmitter {
65
63
  lastActiveMs,
66
64
  };
67
65
  });
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;
75
- });
66
+ // The user's foreground agent is the primary truth. Background agents can keep writing logs, but
67
+ // they should never steal the main HUD away from Codex/Claude while that app is what the user opened.
68
+ sessions.sort((a, b) => hudSelectionRank(a) - hudSelectionRank(b) || a.lastActiveMs - b.lastActiveMs);
76
69
  // A user-pinned tab overrides auto-selection: it becomes the primary and never flips away.
77
70
  const pinned = this.pinnedView(now);
78
71
  const ordered = pinned ? [pinned, ...sessions.filter((s) => s.sourcePath !== pinned.sourcePath)] : sessions;
72
+ const pinnedId = pinned ? this.pinnedId : null;
79
73
  return {
80
74
  mode: this.mode,
81
75
  active: ordered[0] || null,
@@ -83,6 +77,7 @@ export class HudMonitor extends EventEmitter {
83
77
  sessions: ordered,
84
78
  threadCounts: this.threadCounts,
85
79
  recentSessions: this.recentSessions,
80
+ pinnedId,
86
81
  missing: this.missing,
87
82
  updatedAt: new Date().toISOString(),
88
83
  };
@@ -139,13 +134,6 @@ export class HudMonitor extends EventEmitter {
139
134
  if (detected) {
140
135
  // Positive detection wins immediately (real focus switch, e.g. Codex → Claude Desktop).
141
136
  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
137
  }
150
138
  }
151
139
  return this.frontmostClient && this.scores.has(this.frontmostClient) ? this.frontmostClient : null;
@@ -213,60 +201,15 @@ export class HudMonitor extends EventEmitter {
213
201
  return title;
214
202
  }
215
203
  else {
216
- const title = this.claudeTitle(file);
217
- if (title)
218
- return title;
204
+ const cached = this.claudeTitleCache.get(file);
205
+ if (cached !== undefined)
206
+ return cached;
207
+ const title = claudeDisplayTitle(file, client);
208
+ this.claudeTitleCache.set(file, title);
209
+ return title;
219
210
  }
220
211
  return sessionLabel(file, client); // repo/cwd basename fallback
221
212
  }
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
213
  // Codex writes a lightweight index of every thread (id + human title). Load it (mtime-cached) so tabs
271
214
  // show real titles like "Verify file state" instead of a UUID.
272
215
  loadCodexTitles() {
@@ -360,6 +303,18 @@ export class HudMonitor extends EventEmitter {
360
303
  this.emit("state", this.snapshot());
361
304
  }
362
305
  }
306
+ export function hudSelectionRank(session) {
307
+ if (session.focused)
308
+ return 0;
309
+ if (session.live)
310
+ return 1;
311
+ return 2;
312
+ }
313
+ export function claudeDisplayTitle(file, client) {
314
+ const label = sessionLabel(file, client);
315
+ const stamp = sessionTimeLabel(resolveClaudeTranscript(file) ?? file);
316
+ return label === defaultLabel(client) ? stamp : `${label} · ${stamp}`;
317
+ }
363
318
  function detectFrontmostClient() {
364
319
  if (process.platform !== "darwin")
365
320
  return null;
@@ -400,6 +355,19 @@ function sessionLabel(file, client) {
400
355
  const cwd = peekCwd(target);
401
356
  return cwd ? path.basename(cwd) : defaultLabel(client);
402
357
  }
358
+ function sessionTimeLabel(file) {
359
+ try {
360
+ const date = new Date(fs.statSync(file).mtimeMs);
361
+ const month = String(date.getMonth() + 1).padStart(2, "0");
362
+ const day = String(date.getDate()).padStart(2, "0");
363
+ const hour = String(date.getHours()).padStart(2, "0");
364
+ const minute = String(date.getMinutes()).padStart(2, "0");
365
+ return `${month}/${day} ${hour}:${minute}`;
366
+ }
367
+ catch {
368
+ return sessionIdFromPath(file).slice(0, 8) || "session";
369
+ }
370
+ }
403
371
  // The Claude Code active source is the echo-ctx cache (<sessionId>.json), whose mtime only bumps when
404
372
  // the statusline re-renders — stale during a long turn. The transcript grows every tool call, so it's
405
373
  // the true liveness signal. Take the fresher of the two. Cached per cache-file (paths are stable).
@@ -7,4 +7,16 @@ electron_1.contextBridge.exposeInMainWorld("echomemHud", {
7
7
  setOpen(open, height) {
8
8
  electron_1.ipcRenderer.send("hud:set-open", { open, height });
9
9
  },
10
+ setMini(mini) {
11
+ electron_1.ipcRenderer.send("hud:set-mini", { mini });
12
+ },
13
+ startDrag(screenX, screenY) {
14
+ electron_1.ipcRenderer.send("hud:drag-start", { screenX, screenY });
15
+ },
16
+ moveDrag(screenX, screenY) {
17
+ electron_1.ipcRenderer.send("hud:drag-move", { screenX, screenY });
18
+ },
19
+ endDrag() {
20
+ electron_1.ipcRenderer.send("hud:drag-end");
21
+ },
10
22
  });