@echomem/mcp 1.4.16 → 1.4.18

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.
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, screen, shell } from "electron";
7
7
  import { disableAutostart, enableAutostart, isAutostartEnabled } from "./autostart.js";
8
8
  import { createHudServer } from "./server.js";
9
+ import { launchWarmSession } from "./session-launcher.js";
9
10
  const flags = parseFlags(process.argv.slice(2));
10
11
  const mode = parseMode(flags.client);
11
12
  const port = typeof flags.port === "string" ? Number(flags.port) || 17377 : 17377;
@@ -102,8 +103,12 @@ app.whenReady().then(async () => {
102
103
  /* best-effort */
103
104
  }
104
105
  };
106
+ const onLaunchSession = (request) => launchWarmSession(request, {
107
+ openExternal: (externalUrl) => shell.openExternal(externalUrl),
108
+ openPath: (filePath) => shell.openPath(filePath),
109
+ });
105
110
  try {
106
- hudServer = await createHudServer({ mode, port, onReveal, onOpenExternal });
111
+ hudServer = await createHudServer({ mode, port, onReveal, onOpenExternal, onLaunchSession });
107
112
  createWindow(hudServer.url);
108
113
  }
109
114
  catch (error) {
@@ -111,7 +116,7 @@ app.whenReady().then(async () => {
111
116
  createWindow(preferredUrl);
112
117
  return;
113
118
  }
114
- hudServer = await createHudServer({ mode, port: 0, onReveal, onOpenExternal });
119
+ hudServer = await createHudServer({ mode, port: 0, onReveal, onOpenExternal, onLaunchSession });
115
120
  createWindow(hudServer.url);
116
121
  }
117
122
  }).catch((error) => {
@@ -30,9 +30,9 @@ export class HudMonitor extends EventEmitter {
30
30
  claudeTitlesCheckedAt = 0;
31
31
  claudeTitleCache = new Map();
32
32
  lastPersistedRecent = "";
33
- pinnedId = null;
34
- pinnedScore = null;
35
- pinnedFromFamily = null;
33
+ responseTimes = new Map();
34
+ expandedId = null;
35
+ expandedScore = null;
36
36
  constructor(mode = "auto", pollMs = 750) {
37
37
  super();
38
38
  this.mode = mode;
@@ -53,37 +53,37 @@ export class HudMonitor extends EventEmitter {
53
53
  snapshot() {
54
54
  const focusedFamily = this.frontmostPreferredFamily();
55
55
  this.refreshRecent();
56
- this.releasePinAfterForegroundSwitch(focusedFamily);
57
56
  const now = Date.now();
58
57
  const sessions = [...this.scores.values()].map((score) => {
59
58
  // Liveness uses file mtime (real last write), not score.updatedAt — the Claude cache stamps
60
59
  // updatedAt = now on every read, which would make an idle session look permanently live.
61
60
  const mtimeMs = this.mtimes.get(score.client) ?? (Date.parse(score.updatedAt) || now);
62
- const lastActiveMs = Math.max(0, now - mtimeMs);
61
+ const responseAtMs = this.responseTimeFor(score.client, score.sourcePath, mtimeMs);
62
+ const lastActiveMs = Math.max(0, now - responseAtMs);
63
63
  return {
64
64
  ...score,
65
- live: lastActiveMs < LIVE_WINDOW_MS,
65
+ live: Math.max(0, now - mtimeMs) < LIVE_WINDOW_MS,
66
66
  focused: focusedFamily === agentFamily(score.client),
67
67
  label: this.labelFor(score),
68
68
  lastActiveMs,
69
69
  };
70
70
  });
71
- // The user's foreground agent is the primary truth. Background agents can keep writing logs, but
72
- // they should never steal the main HUD away from Codex/Claude while that app is what the user opened.
73
- sessions.sort((a, b) => hudSelectionRank(a) - hudSelectionRank(b) || a.lastActiveMs - b.lastActiveMs);
74
- // A pin selects one session while the user remains in the same agent. Crossing to the other
75
- // foreground agent releases it so Auto can follow the workflow again.
76
- const pinned = this.pinnedView(now);
77
- const ordered = pinned ? [pinned, ...sessions.filter((s) => s.sourcePath !== pinned.sourcePath)] : sessions;
78
- const pinnedId = pinned ? this.pinnedId : null;
71
+ // Only a newer assistant response can promote a conversation. Focus, clicks, and expansion are
72
+ // view state and must never rewrite chronology; sourcePath is only a deterministic exact-tie key.
73
+ sessions.sort((a, b) => a.lastActiveMs - b.lastActiveMs || a.sourcePath.localeCompare(b.sourcePath));
74
+ const expanded = this.expandedView(now);
75
+ const views = expanded
76
+ ? [expanded, ...sessions.filter((s) => s.sourcePath !== expanded.sourcePath)]
77
+ : sessions;
79
78
  return {
80
79
  mode: this.mode,
81
- active: ordered[0] || null,
82
- scores: ordered,
83
- sessions: ordered,
80
+ active: sessions[0] || null,
81
+ expanded,
82
+ expandedId: expanded ? this.expandedId : null,
83
+ scores: views,
84
+ sessions: views,
84
85
  threadCounts: this.threadCounts,
85
86
  recentSessions: this.recentSessions,
86
- pinnedId,
87
87
  missing: this.missing,
88
88
  updatedAt: new Date().toISOString(),
89
89
  };
@@ -96,29 +96,20 @@ export class HudMonitor extends EventEmitter {
96
96
  this.labelCache.set(score.sourcePath, label);
97
97
  return label;
98
98
  }
99
- // Pin a recent-session id as the primary view (or null to return to auto-selection).
100
- pin(id) {
101
- if (id === this.pinnedId)
99
+ // Expand one recent-session row in place (or null to collapse it). This deliberately does not
100
+ // participate in active-session selection, so inspecting history cannot move the timeline.
101
+ expand(id) {
102
+ if (id === this.expandedId)
102
103
  return;
103
- this.pinnedId = id;
104
- this.pinnedScore = null;
105
- this.pinnedFromFamily = id ? this.frontmostPreferredFamily() : null;
104
+ this.expandedId = id;
105
+ this.expandedScore = null;
106
106
  }
107
- releasePinAfterForegroundSwitch(focusedFamily) {
108
- if (!this.pinnedId || !this.pinnedFromFamily || !focusedFamily)
109
- return;
110
- if (focusedFamily === this.pinnedFromFamily)
111
- return;
112
- this.pinnedId = null;
113
- this.pinnedScore = null;
114
- this.pinnedFromFamily = null;
115
- }
116
- // The user-selected tab, scored on demand (cached by file signature). null if nothing is pinned or
117
- // the pinned session has aged out of the recent list.
118
- pinnedView(now) {
119
- if (!this.pinnedId)
107
+ // The expanded history row, scored on demand (cached by file signature). null if nothing is
108
+ // expanded or the session has aged out of the recent list.
109
+ expandedView(now) {
110
+ if (!this.expandedId)
120
111
  return null;
121
- const rec = this.recentSessions.find((r) => r.id === this.pinnedId);
112
+ const rec = this.recentSessions.find((r) => r.id === this.expandedId);
122
113
  if (!rec)
123
114
  return null;
124
115
  let stat = null;
@@ -129,16 +120,23 @@ export class HudMonitor extends EventEmitter {
129
120
  return null;
130
121
  }
131
122
  const sig = `${rec.sourcePath}:${stat.size}:${stat.mtimeMs}`;
132
- if (!this.pinnedScore || this.pinnedScore.sig !== sig) {
123
+ if (!this.expandedScore || this.expandedScore.sig !== sig) {
133
124
  try {
134
- this.pinnedScore = { sig, score: adapters[rec.client].score(rec.sourcePath) };
125
+ this.expandedScore = { sig, score: adapters[rec.client].score(rec.sourcePath) };
135
126
  }
136
127
  catch {
137
128
  return null;
138
129
  }
139
130
  }
140
- const lastActiveMs = Math.max(0, now - stat.mtimeMs);
141
- return { ...this.pinnedScore.score, live: lastActiveMs < LIVE_WINDOW_MS, focused: true, label: rec.label || rec.title, lastActiveMs };
131
+ const activityMtimeMs = liveMtime(rec.client, rec.sourcePath, stat.mtimeMs);
132
+ const lastActiveMs = Math.max(0, now - this.responseTimeFor(rec.client, rec.sourcePath, activityMtimeMs));
133
+ return {
134
+ ...this.expandedScore.score,
135
+ live: Math.max(0, now - activityMtimeMs) < LIVE_WINDOW_MS,
136
+ focused: false,
137
+ label: rec.label || rec.title,
138
+ lastActiveMs,
139
+ };
142
140
  }
143
141
  frontmostPreferredFamily() {
144
142
  if (this.mode !== "auto" && this.mode !== "both")
@@ -189,8 +187,10 @@ export class HudMonitor extends EventEmitter {
189
187
  const newest = newestByClient.get(adapter.client);
190
188
  if (!newest || mtimeMs > newest.mtimeMs)
191
189
  newestByClient.set(adapter.client, { file, mtimeMs });
192
- if (age < RECENT_WINDOW_MS)
193
- recent.push(this.buildRecent(adapter.client, file, age));
190
+ if (age < RECENT_WINDOW_MS) {
191
+ const responseAtMs = this.responseTimeFor(adapter.client, file, mtimeMs);
192
+ recent.push(this.buildRecent(adapter.client, file, age, Math.max(0, now - responseAtMs)));
193
+ }
194
194
  }
195
195
  counts[adapter.client] = n;
196
196
  }
@@ -199,24 +199,44 @@ export class HudMonitor extends EventEmitter {
199
199
  // Always keep each agent's latest session reachable as a tab (so you can switch to "my last Codex"
200
200
  // even if it's been idle longer than the window). Appended if the window didn't already include it.
201
201
  for (const [client, info] of newestByClient) {
202
- if (!list.some((r) => r.client === client))
203
- list.push(this.buildRecent(client, info.file, now - info.mtimeMs));
202
+ if (!list.some((r) => r.client === client)) {
203
+ const activityAgeMs = now - info.mtimeMs;
204
+ const responseAgeMs = Math.max(0, now - this.responseTimeFor(client, info.file, info.mtimeMs));
205
+ list.push(this.buildRecent(client, info.file, activityAgeMs, responseAgeMs));
206
+ }
204
207
  }
205
208
  this.threadCounts = counts;
206
209
  this.recentSessions = list;
207
210
  this.persistRecent();
208
211
  }
209
- buildRecent(client, file, age) {
212
+ buildRecent(client, file, activityAgeMs, responseAgeMs) {
210
213
  return {
211
- id: sessionIdFromPath(file),
214
+ id: recentSessionId(client, file),
212
215
  client,
213
216
  title: this.titleFor(client, file),
214
217
  label: sessionLabel(file, client),
215
218
  sourcePath: file,
216
- lastActiveMs: age,
217
- live: age < LIVE_WINDOW_MS,
219
+ lastActiveMs: responseAgeMs,
220
+ live: activityAgeMs < LIVE_WINDOW_MS,
218
221
  };
219
222
  }
223
+ responseTimeFor(client, file, fallbackMtimeMs) {
224
+ const transcript = client === "codex" ? file : resolveClaudeTranscript(file) ?? file;
225
+ let signature = "";
226
+ try {
227
+ const stat = fs.statSync(transcript);
228
+ signature = `${transcript}:${stat.size}:${stat.mtimeMs}`;
229
+ }
230
+ catch {
231
+ return fallbackMtimeMs;
232
+ }
233
+ const cached = this.responseTimes.get(transcript);
234
+ if (cached?.signature === signature)
235
+ return cached.timestampMs;
236
+ const timestampMs = latestAssistantResponseAt(transcript, client) || cached?.timestampMs || fallbackMtimeMs;
237
+ this.responseTimes.set(transcript, { signature, timestampMs });
238
+ return timestampMs;
239
+ }
220
240
  titleFor(client, file) {
221
241
  if (client === "codex") {
222
242
  const uuid = codexUuidFromPath(file);
@@ -414,12 +434,106 @@ export function agentFamilyForApplication(name, bundleId = "") {
414
434
  export function agentFamily(client) {
415
435
  return client === "codex" ? "codex" : "claude";
416
436
  }
437
+ // Return the timestamp of the newest assistant text response, ignoring user turns, tool calls,
438
+ // tool results, and other transcript writes. Scanning backwards keeps the common path cheap while
439
+ // making ordering depend on conversation responses instead of filesystem activity.
440
+ export function latestAssistantResponseAt(file, client) {
441
+ const chunkSize = 256 * 1024;
442
+ const maxScanBytes = 8 * 1024 * 1024;
443
+ let fd = null;
444
+ try {
445
+ fd = fs.openSync(file, "r");
446
+ const size = fs.fstatSync(fd).size;
447
+ let position = size;
448
+ let scanned = 0;
449
+ let carry = "";
450
+ while (position > 0 && scanned < maxScanBytes) {
451
+ const bytes = Math.min(chunkSize, position, maxScanBytes - scanned);
452
+ position -= bytes;
453
+ scanned += bytes;
454
+ const buffer = Buffer.allocUnsafe(bytes);
455
+ fs.readSync(fd, buffer, 0, bytes, position);
456
+ const lines = (buffer.toString("utf8") + carry).split("\n");
457
+ carry = lines.shift() || "";
458
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
459
+ const timestampMs = assistantResponseTimestamp(lines[i], client);
460
+ if (timestampMs !== null)
461
+ return timestampMs;
462
+ }
463
+ }
464
+ return assistantResponseTimestamp(carry, client);
465
+ }
466
+ catch {
467
+ return null;
468
+ }
469
+ finally {
470
+ if (fd !== null)
471
+ fs.closeSync(fd);
472
+ }
473
+ }
474
+ function assistantResponseTimestamp(line, client) {
475
+ if (!line.trim())
476
+ return null;
477
+ let row;
478
+ try {
479
+ row = JSON.parse(line);
480
+ }
481
+ catch {
482
+ return null;
483
+ }
484
+ if (!isRecord(row))
485
+ return null;
486
+ const payload = isRecord(row.payload) ? row.payload : {};
487
+ const timestamp = typeof row.timestamp === "string"
488
+ ? Date.parse(row.timestamp)
489
+ : typeof payload.timestamp === "string"
490
+ ? Date.parse(payload.timestamp)
491
+ : Number.NaN;
492
+ if (!Number.isFinite(timestamp))
493
+ return null;
494
+ if (client === "codex") {
495
+ const type = typeof payload.type === "string" ? payload.type : "";
496
+ if ((type === "agent_message" || type === "assistant_message") && hasText(payload.message ?? payload.content)) {
497
+ return timestamp;
498
+ }
499
+ if (type === "message" && payload.role === "assistant" && hasText(payload.content ?? payload.message)) {
500
+ return timestamp;
501
+ }
502
+ return null;
503
+ }
504
+ if (row.type !== "assistant")
505
+ return null;
506
+ const message = isRecord(row.message) ? row.message : {};
507
+ if (message.role !== undefined && message.role !== "assistant")
508
+ return null;
509
+ return hasText(message.content) ? timestamp : null;
510
+ }
511
+ function hasText(value) {
512
+ if (typeof value === "string")
513
+ return Boolean(value.trim());
514
+ if (!Array.isArray(value))
515
+ return false;
516
+ return value.some((block) => {
517
+ if (typeof block === "string")
518
+ return Boolean(block.trim());
519
+ if (!isRecord(block))
520
+ return false;
521
+ const type = typeof block.type === "string" ? block.type : "";
522
+ return (type === "text" || type === "output_text") && typeof block.text === "string" && Boolean(block.text.trim());
523
+ });
524
+ }
417
525
  function isRecord(value) {
418
526
  return typeof value === "object" && value !== null && !Array.isArray(value);
419
527
  }
420
528
  function sessionIdFromPath(file) {
421
529
  return codexUuidFromPath(file) || path.basename(file).replace(/\.jsonl$|\.json$/, "");
422
530
  }
531
+ // Provider-qualified identity keeps one combined session feed safe when two clients reuse the same
532
+ // UUID (for example, a Claude Code session mirrored into Claude Desktop). The source id remains
533
+ // recoverable from sourcePath for provider-specific deep links and title metadata joins.
534
+ export function recentSessionId(client, file) {
535
+ return `${client}:${sessionIdFromPath(file)}`;
536
+ }
423
537
  function codexUuidFromPath(file) {
424
538
  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}/);
425
539
  return match ? match[0] : "";
@@ -11,6 +11,7 @@ import { homePath, newestFile, walkFiles } from "./fs.js";
11
11
  import { KeyStore } from "../keystore.js";
12
12
  import { assembleCodex, assembleClaude } from "../migrate.js";
13
13
  import { readBillingAlert } from "../billing-alert.js";
14
+ import { readSessionWorkingDirectory, } from "./session-launcher.js";
14
15
  const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
15
16
  const PRICING_URL = (process.env.ECHO_PRICING_URL || "https://echoknows.com/pricing").replace(/\/$/, "");
16
17
  export async function createHudServer(opts = {}) {
@@ -89,15 +90,17 @@ export async function createHudServer(opts = {}) {
89
90
  });
90
91
  return;
91
92
  }
92
- if (url.pathname === "/select") {
93
+ if (url.pathname === "/expand" || url.pathname === "/select") {
93
94
  const id = url.searchParams.get("id");
94
- monitor.pin(id && id !== "auto" ? id : null);
95
+ // /select remains as a compatibility alias, but selection no longer replaces the top card:
96
+ // it only expands that timeline row in place.
97
+ monitor.expand(id && id !== "auto" ? id : null);
95
98
  latest = monitor.snapshot();
96
99
  const payload = `data: ${JSON.stringify(latest)}\n\n`;
97
100
  for (const client of clients)
98
101
  client.write(payload);
99
102
  res.writeHead(200, { "content-type": "application/json" });
100
- res.end(JSON.stringify({ ok: true, pinned: id && id !== "auto" ? id : null }));
103
+ res.end(JSON.stringify({ ok: true, expanded: id && id !== "auto" ? id : null }));
101
104
  return;
102
105
  }
103
106
  if (url.pathname === "/viewer") {
@@ -242,6 +245,15 @@ export async function createHudServer(opts = {}) {
242
245
  });
243
246
  return;
244
247
  }
248
+ if (url.pathname === "/launch") {
249
+ handleLaunch(req, latest, opts.onLaunchSession, res).catch((error) => {
250
+ if (res.writableEnded)
251
+ return;
252
+ res.writeHead(200, { "content-type": "application/json" });
253
+ res.end(JSON.stringify({ ok: false, reason: error instanceof Error ? error.message : "launch_failed" }));
254
+ });
255
+ return;
256
+ }
245
257
  if (url.pathname === "/checkpoint-status") {
246
258
  handleCheckpointStatus(latest, res).catch((error) => {
247
259
  if (res.writableEnded)
@@ -355,6 +367,44 @@ function appendSource(url, source) {
355
367
  return url;
356
368
  }
357
369
  }
370
+ async function handleLaunch(req, latest, launch, res) {
371
+ const respond = (obj) => {
372
+ if (res.writableEnded)
373
+ return;
374
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
375
+ res.end(JSON.stringify(obj));
376
+ };
377
+ if (req.method !== "POST")
378
+ return respond({ ok: false, reason: "method_not_allowed" });
379
+ const origin = req.headers.origin;
380
+ if (origin && !/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(origin)) {
381
+ return respond({ ok: false, reason: "origin_not_allowed" });
382
+ }
383
+ if (!launch)
384
+ return respond({ ok: false, reason: "no_desktop" });
385
+ const active = latest.active;
386
+ if (!active || !active.sourcePath)
387
+ return respond({ ok: false, reason: "no_active_session" });
388
+ const body = await readRequestJson(req, 600_000);
389
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
390
+ const expectedSource = typeof body.sourcePath === "string" ? body.sourcePath : "";
391
+ if (!prompt || prompt.length > 512_000)
392
+ return respond({ ok: false, reason: "invalid_context" });
393
+ if (!expectedSource || expectedSource !== active.sourcePath)
394
+ return respond({ ok: false, reason: "session_changed" });
395
+ const sessionFile = resolveSessionFile(active.client, active.sourcePath);
396
+ const assembled = active.client === "codex" ? assembleCodex(sessionFile) : assembleClaude(sessionFile);
397
+ const cwd = readSessionWorkingDirectory(sessionFile) || (assembled?.cwd && path.isAbsolute(assembled.cwd) ? assembled.cwd : "");
398
+ if (!assembled || !cwd)
399
+ return respond({ ok: false, reason: "workspace_missing" });
400
+ const result = await launch({
401
+ client: active.client,
402
+ cwd,
403
+ prompt,
404
+ title: assembled.title,
405
+ });
406
+ respond(result);
407
+ }
358
408
  async function handleCheckpointStatus(latest, res) {
359
409
  const respond = (obj) => {
360
410
  if (res.writableEnded)
@@ -668,3 +718,29 @@ function serveHudAsset(pathname, res) {
668
718
  fs.createReadStream(assetPath).pipe(res);
669
719
  return true;
670
720
  }
721
+ function readRequestJson(req, maxBytes) {
722
+ return new Promise((resolve, reject) => {
723
+ const chunks = [];
724
+ let size = 0;
725
+ req.on("data", (chunk) => {
726
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
727
+ size += buffer.length;
728
+ if (size > maxBytes) {
729
+ reject(new Error("request_too_large"));
730
+ req.destroy();
731
+ return;
732
+ }
733
+ chunks.push(buffer);
734
+ });
735
+ req.on("end", () => {
736
+ try {
737
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
738
+ resolve(typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {});
739
+ }
740
+ catch {
741
+ reject(new Error("invalid_json"));
742
+ }
743
+ });
744
+ req.on("error", reject);
745
+ });
746
+ }