@echomem/mcp 1.4.8 → 1.4.10

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.
Files changed (64) hide show
  1. package/README.md +23 -3
  2. package/assets/canonical-scorer/README.md +18 -0
  3. package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
  4. package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
  5. package/assets/canonical-scorer/golden_anchors.mjs +83 -0
  6. package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
  7. package/assets/hud/github.svg +1 -0
  8. package/dist/city/README.md +9 -0
  9. package/dist/city/echo-ai-city-only.html +1067 -107
  10. package/dist/codex-session-files.js +283 -0
  11. package/dist/codex-sync.js +7 -2
  12. package/dist/context-analysis/canonical-golden.js +47 -0
  13. package/dist/context-analysis/claude-canonical-adapter.js +315 -0
  14. package/dist/context-analysis/claude-native-canonical.js +1216 -0
  15. package/dist/context-analysis/vendored-canonical.js +793 -0
  16. package/dist/context-analysis/workspace-report.js +1838 -0
  17. package/dist/context-metrics/calculate.js +43 -0
  18. package/dist/context-metrics/estimator.js +45 -0
  19. package/dist/context-metrics/ledger.js +507 -0
  20. package/dist/context-metrics/model-limits.js +26 -0
  21. package/dist/context-metrics/parse-claude.js +227 -0
  22. package/dist/context-metrics/parse-codex.js +276 -0
  23. package/dist/context-metrics/types.js +1 -0
  24. package/dist/forensics-10-problems.js +7 -6
  25. package/dist/forensics.js +863 -132
  26. package/dist/hud/adapters.js +77 -202
  27. package/dist/hud/cli.js +0 -0
  28. package/dist/hud/efficiency.js +447 -0
  29. package/dist/hud/electron-main.js +3 -2
  30. package/dist/hud/fs.js +14 -0
  31. package/dist/hud/metric.js +17 -102
  32. package/dist/hud/monitor.js +136 -28
  33. package/dist/hud/render.js +4 -3
  34. package/dist/hud/server.js +30 -0
  35. package/dist/hud/web.js +622 -353
  36. package/dist/index.js +7 -3
  37. package/dist/local-data-paths.js +87 -0
  38. package/dist/migrate.js +37 -29
  39. package/dist/report.js +101 -40
  40. package/dist/setup-page/client-core.js +475 -0
  41. package/dist/setup-page/client-extraction.js +550 -0
  42. package/dist/setup-page/client-lifecycle.js +116 -0
  43. package/dist/setup-page/client-report-audit.js +818 -0
  44. package/dist/setup-page/client-report-city.js +204 -0
  45. package/dist/setup-page/client-report.js +6 -0
  46. package/dist/setup-page/client.js +15 -0
  47. package/dist/setup-page/document.js +37 -0
  48. package/dist/setup-page/styles-city-report.js +880 -0
  49. package/dist/setup-page/styles-context-audit.js +470 -0
  50. package/dist/setup-page/styles-extraction.js +821 -0
  51. package/dist/setup-page/styles-foundation.js +231 -0
  52. package/dist/setup-page/styles.js +11 -0
  53. package/dist/setup-page.js +15 -2538
  54. package/dist/setup-preview.js +245 -0
  55. package/dist/setup.js +456 -44
  56. package/package.json +8 -7
  57. package/templates/echomem-recall.md +2 -2
  58. package/dist/city/10-problems-report.html +0 -649
  59. package/dist/city/_live.html +0 -37
  60. package/dist/city/_serve.mjs +0 -45
  61. package/dist/city/card-data.json +0 -15
  62. package/dist/city/city-data.json +0 -248
  63. package/dist/city/echo-ai-city-only.template.html +0 -1272
  64. package/dist/city/generate-echo-city-only.mjs +0 -112
package/dist/setup.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `echomem-mcp setup | login | unlock | status | logout` — onboarding for the local bridge (spec §8).
2
+ * `echomem-mcp init | setup | login | unlock | status | logout` — onboarding for the local bridge (spec §8).
3
3
  *
4
4
  * Design goals from the spec:
5
5
  * - One command → one browser approval → one reload.
@@ -20,7 +20,7 @@ import fs from "node:fs";
20
20
  import os from "node:os";
21
21
  import path from "node:path";
22
22
  import readline from "node:readline";
23
- import { fileURLToPath } from "node:url";
23
+ import { fileURLToPath, pathToFileURL } from "node:url";
24
24
  import axios from "axios";
25
25
  import { KeyStore } from "./keystore.js";
26
26
  import { fetchEncryptionConfig, deriveAndVerifyKey, verifyKeyB64 } from "./encryption.js";
@@ -28,7 +28,8 @@ import { collect, runReport, buildStatsPayload } from "./report.js";
28
28
  import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableFastDiscovery, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
29
29
  import { syncCodexUsage } from "./codex-sync.js";
30
30
  import { renderSetupPage } from "./setup-page.js";
31
- import { repoLabel } from "./forensics.js";
31
+ import { parseSetupPreviewState } from "./setup-preview.js";
32
+ import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
32
33
  import { installHooks } from "./hud/hooks.js";
33
34
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
34
35
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
@@ -40,6 +41,50 @@ const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.
40
41
  function home(...p) {
41
42
  return path.join(os.homedir(), ...p);
42
43
  }
44
+ /** Map a source entry to its compiled sibling without ever guessing outside this package. */
45
+ export function compiledDistPathForSource(entry) {
46
+ if (!entry.endsWith(".ts") || !path.isAbsolute(entry))
47
+ return null;
48
+ const normalized = path.normalize(entry);
49
+ const srcSegment = `${path.sep}src${path.sep}`;
50
+ const srcIndex = normalized.lastIndexOf(srcSegment);
51
+ if (srcIndex < 0)
52
+ return null;
53
+ const packageRoot = normalized.slice(0, srcIndex) || path.parse(normalized).root;
54
+ const relativeSource = normalized.slice(srcIndex + srcSegment.length);
55
+ const candidate = path.join(packageRoot, "dist", relativeSource.replace(/\.ts$/, ".js"));
56
+ try {
57
+ const real = fs.realpathSync(candidate);
58
+ if (!fs.statSync(real).isFile())
59
+ return null;
60
+ fs.accessSync(real, fs.constants.R_OK);
61
+ return real;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function runtimeModuleUrl(name) {
68
+ const jsUrl = new URL(`./${name}.js`, import.meta.url);
69
+ if (jsUrl.protocol !== "file:")
70
+ return jsUrl.href;
71
+ const jsPath = fileURLToPath(jsUrl);
72
+ try {
73
+ if (fs.statSync(jsPath).isFile())
74
+ return jsUrl.href;
75
+ }
76
+ catch {
77
+ /* Source-mode runs do not have an adjacent .js file. */
78
+ }
79
+ const tsPath = fileURLToPath(new URL(`./${name}.ts`, import.meta.url));
80
+ const compiled = compiledDistPathForSource(tsPath);
81
+ if (compiled)
82
+ return pathToFileURL(compiled).href;
83
+ if (fs.existsSync(tsPath)) {
84
+ throw new Error(`The local ${name} worker is not built. Run npm --prefix packages/mcp-server run build and retry.`);
85
+ }
86
+ return jsUrl.href;
87
+ }
43
88
  /** Known clients and where their MCP server map lives. */
44
89
  export function knownClients() {
45
90
  const appSupport = process.platform === "darwin"
@@ -81,9 +126,15 @@ export function buildServerEntry(opts = {}) {
81
126
  // Trade-off: the node path is version-specific under nvm — re-run `setup` after a Node upgrade.
82
127
  try {
83
128
  const entry = fs.realpathSync(process.argv[1] || "");
84
- if (entry && fs.existsSync(entry)) {
85
- return { command: process.execPath, args: [entry] };
129
+ if (entry && fs.existsSync(entry) && !isEphemeralNpxPath(entry)) {
130
+ return { command: process.execPath, args: [compiledDistPathForSource(entry) ?? entry] };
86
131
  }
132
+ // We're running from an EPHEMERAL npx cache (`npx @echomem/mcp …` with no global install). npx GCs
133
+ // those `_npx/<hash>` dirs, so pinning a client to this path works until the next cleanup, then the
134
+ // MCP server silently vanishes. Never write it — prefer a durable global install of the same package.
135
+ const globalEntry = resolveGlobalEntry();
136
+ if (globalEntry)
137
+ return { command: process.execPath, args: [globalEntry] };
87
138
  }
88
139
  catch {
89
140
  /* couldn't resolve a local install — fall through to npx */
@@ -91,6 +142,33 @@ export function buildServerEntry(opts = {}) {
91
142
  // Fallback (unresolved local install): at least drop `-y` so npx doesn't auto-INSTALL on every start.
92
143
  return { command: "npx", args: ["@echomem/mcp"] };
93
144
  }
145
+ /** True when a resolved entry lives inside npx's throwaway cache (`…/_npx/<hash>/…`). */
146
+ function isEphemeralNpxPath(entry) {
147
+ return entry.split(path.sep).includes("_npx");
148
+ }
149
+ /**
150
+ * Locate a DURABLE global install of the bridge (the one `npm i -g @echomem/mcp` creates). Global
151
+ * modules sit next to the running node — `<node>/../lib/node_modules` (nvm/unix) or `<node>/node_modules`
152
+ * (Windows). Returns the realpath'd dist entry, or null when the package isn't globally installed.
153
+ */
154
+ function resolveGlobalEntry() {
155
+ const nodeDir = path.dirname(process.execPath);
156
+ const pkgParts = MCP_PACKAGE_NAME.split("/"); // ["@echomem", "mcp"]
157
+ const candidates = [
158
+ path.join(nodeDir, "..", "lib", "node_modules", ...pkgParts, "dist", "index.js"),
159
+ path.join(nodeDir, "node_modules", ...pkgParts, "dist", "index.js"),
160
+ ];
161
+ for (const candidate of candidates) {
162
+ try {
163
+ if (fs.existsSync(candidate))
164
+ return fs.realpathSync(candidate);
165
+ }
166
+ catch {
167
+ /* keep trying */
168
+ }
169
+ }
170
+ return null;
171
+ }
94
172
  /** The TOML block EchoMem adds to ~/.codex/config.toml. No secret — the bridge reads the keystore. */
95
173
  export function codexTomlBlock(entry) {
96
174
  const command = JSON.stringify(String(entry.command));
@@ -484,6 +562,63 @@ function openClaudeDesktop() {
484
562
  detail: failures.join(" | "),
485
563
  };
486
564
  }
565
+ const LOCAL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
566
+ // `claude --resume <id>` only finds sessions that belong to the current project directory, so
567
+ // recover the session's original cwd from its transcript before resuming.
568
+ function claudeSessionCwd(sessionId) {
569
+ try {
570
+ const projectsDir = path.join(os.homedir(), ".claude", "projects");
571
+ for (const dir of fs.readdirSync(projectsDir)) {
572
+ const file = path.join(projectsDir, dir, `${sessionId}.jsonl`);
573
+ if (!fs.existsSync(file))
574
+ continue;
575
+ const fd = fs.openSync(file, "r");
576
+ try {
577
+ const head = Buffer.alloc(65536);
578
+ const read = fs.readSync(fd, head, 0, head.length, 0);
579
+ const match = head.toString("utf8", 0, read).match(/"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/);
580
+ return match ? JSON.parse(`"${match[1]}"`) : null;
581
+ }
582
+ finally {
583
+ fs.closeSync(fd);
584
+ }
585
+ }
586
+ }
587
+ catch {
588
+ /* fall through to a plain resume */
589
+ }
590
+ return null;
591
+ }
592
+ function shellQuote(value) {
593
+ return `'${value.replace(/'/g, `'\\''`)}'`;
594
+ }
595
+ function openExistingAgentSession(source, sessionId) {
596
+ if (process.platform !== "darwin")
597
+ return { ok: false, message: "Opening local agent sessions is currently available on macOS." };
598
+ if (!LOCAL_SESSION_ID_RE.test(sessionId))
599
+ return { ok: false, message: "The local session identifier is invalid." };
600
+ try {
601
+ if (source === "codex") {
602
+ execFileSync("open", [`codex://threads/${sessionId}`], { stdio: "pipe" });
603
+ return { ok: true, message: "Opened the original session in Codex." };
604
+ }
605
+ if (source === "claude-code") {
606
+ const claude = firstExisting(["/opt/homebrew/bin/claude", "/usr/local/bin/claude"]) || "claude";
607
+ const cwd = claudeSessionCwd(sessionId);
608
+ const resume = `${claude} --resume ${sessionId}`;
609
+ const command = cwd && fs.existsSync(cwd) ? `cd ${shellQuote(cwd)} && ${resume}` : resume;
610
+ execFileSync("osascript", [
611
+ "-e", "tell application \"Terminal\" to activate",
612
+ "-e", `tell application \"Terminal\" to do script ${JSON.stringify(command)}`,
613
+ ], { stdio: "pipe" });
614
+ return { ok: true, message: "Opened the original Claude Code session in Terminal." };
615
+ }
616
+ return { ok: false, message: "Unsupported agent session source." };
617
+ }
618
+ catch (error) {
619
+ return { ok: false, message: "Could not open the original local session.", detail: commandFailureMessage(error) };
620
+ }
621
+ }
487
622
  function commandFailureMessage(error) {
488
623
  const maybe = error;
489
624
  if (Buffer.isBuffer(maybe.stderr)) {
@@ -630,6 +765,12 @@ function repoCityArtifactsRoot() {
630
765
  function serveRepoCityAsset(reqPath, res) {
631
766
  const root = repoCityArtifactsRoot();
632
767
  const rel = reqPath === "/city" || reqPath === "/city/" ? "echo-ai-city-only.html" : decodeURIComponent(reqPath.slice("/city/".length));
768
+ // Archives stay in the checkout for recovery, but must never become a localhost UI surface.
769
+ const normalizedRel = rel.replace(/\\/g, "/");
770
+ if (normalizedRel === "archive" || normalizedRel.startsWith("archive/")) {
771
+ res.writeHead(404).end("not found");
772
+ return true;
773
+ }
633
774
  const filePath = path.resolve(root, rel);
634
775
  const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
635
776
  if (!filePath.startsWith(rootWithSep)) {
@@ -749,7 +890,7 @@ function serveAgentIcon(reqPath, res) {
749
890
  return true;
750
891
  }
751
892
  function discoverMigratableSessionsOffThread() {
752
- const migrateUrl = new URL("./migrate.js", import.meta.url).href;
893
+ const migrateUrl = runtimeModuleUrl("migrate");
753
894
  const code = `
754
895
  import { parentPort } from "node:worker_threads";
755
896
  import { discoverMigratableSessions } from ${JSON.stringify(migrateUrl)};
@@ -795,15 +936,29 @@ function discoverMigratableSessionsOffThread() {
795
936
  });
796
937
  });
797
938
  }
939
+ function forensicStageLabel(stage) {
940
+ if (stage === "reading-transcripts")
941
+ return "Reading transcript files";
942
+ if (stage === "building-summary")
943
+ return "Building scan summary";
944
+ if (stage === "classifying-repeated-context")
945
+ return "Classifying repeated context";
946
+ if (stage === "finalizing-report")
947
+ return "Finalizing report";
948
+ return "Starting local scan";
949
+ }
798
950
  /** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
799
951
  * blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
800
- function buildForensicReportOffThread(onProgress) {
801
- const forensicsUrl = new URL("./forensics.js", import.meta.url).href;
952
+ export function buildForensicReportOffThread(onProgress, options = {}) {
953
+ const forensicsUrl = runtimeModuleUrl("forensics");
802
954
  const code = `
803
955
  import { parentPort } from "node:worker_threads";
804
956
  import { buildForensicReport } from ${JSON.stringify(forensicsUrl)};
805
957
  try {
806
- const report = buildForensicReport({ onProgress: (done, total) => parentPort?.postMessage({ progress: { done, total } }) });
958
+ const report = await buildForensicReport({
959
+ includeLegacyGoldenStandard: false,
960
+ onProgress: (done, total, stage, detail) => parentPort?.postMessage({ progress: { done, total, stage, detail } }),
961
+ });
807
962
  parentPort?.postMessage({ ok: true, report });
808
963
  } catch (error) {
809
964
  parentPort?.postMessage({ ok: false, message: error instanceof Error ? error.message : String(error) });
@@ -812,30 +967,50 @@ function buildForensicReportOffThread(onProgress) {
812
967
  const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
813
968
  return new Promise((resolve, reject) => {
814
969
  let settled = false;
970
+ const requestedTimeoutMs = options.timeoutMs ?? 15 * 60_000;
971
+ const timeoutMs = Number.isFinite(requestedTimeoutMs) ? Math.max(1, requestedTimeoutMs) : 15 * 60_000;
972
+ const timeout = setTimeout(() => {
973
+ if (settled)
974
+ return;
975
+ settled = true;
976
+ void worker.terminate();
977
+ const error = new Error(`Local forensic report timed out after ${timeoutMs}ms`);
978
+ error.code = "REPORT_SCAN_TIMEOUT";
979
+ reject(error);
980
+ }, timeoutMs);
981
+ timeout.unref?.();
982
+ const finish = (result) => {
983
+ if (settled)
984
+ return;
985
+ settled = true;
986
+ clearTimeout(timeout);
987
+ void worker.terminate();
988
+ if (result.ok)
989
+ resolve(result.report);
990
+ else
991
+ reject(result.error);
992
+ };
815
993
  worker.on("message", (message) => {
994
+ if (settled)
995
+ return;
816
996
  const msg = message;
817
997
  if (msg.progress) {
818
- onProgress?.(msg.progress.done, msg.progress.total);
998
+ onProgress?.(msg.progress);
819
999
  return;
820
1000
  }
821
- settled = true;
822
- if (msg.ok === true && msg.report && typeof msg.report === "object")
823
- resolve(msg.report);
824
- else
825
- reject(new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed"));
826
- void worker.terminate();
1001
+ if (msg.ok === true && msg.report && typeof msg.report === "object") {
1002
+ finish({ ok: true, report: msg.report });
1003
+ return;
1004
+ }
1005
+ finish({ ok: false, error: new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed") });
827
1006
  });
828
1007
  worker.once("error", (error) => {
829
- if (settled)
830
- return;
831
- settled = true;
832
- reject(error);
1008
+ finish({ ok: false, error });
833
1009
  });
834
1010
  worker.once("exit", (code) => {
835
1011
  if (settled)
836
1012
  return;
837
- settled = true;
838
- reject(new Error(`Forensic report worker exited (code ${code}) without a result`));
1013
+ finish({ ok: false, error: new Error(`Forensic report worker exited (code ${code}) without a result`) });
839
1014
  });
840
1015
  });
841
1016
  }
@@ -844,15 +1019,62 @@ export function respondMigrate(res, body, status = 200) {
844
1019
  return;
845
1020
  res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
846
1021
  }
1022
+ function safeForensicError(error) {
1023
+ const code = error && typeof error === "object" && "code" in error
1024
+ ? String(error.code || "")
1025
+ : "";
1026
+ if (code === "REPORT_SCAN_TIMEOUT") {
1027
+ return {
1028
+ code,
1029
+ message: "The local workspace scan took too long and was stopped. No backup data was substituted. Rerun setup to retry.",
1030
+ };
1031
+ }
1032
+ return {
1033
+ code: "REPORT_BUILD_FAILED",
1034
+ message: "EchoMem could not finish the local workspace scan. No backup data was substituted. Rerun setup to retry.",
1035
+ };
1036
+ }
1037
+ function publicRunningForensicProgress(value) {
1038
+ if (!value || typeof value !== "object")
1039
+ return null;
1040
+ const progress = value;
1041
+ if (progress.status !== "running")
1042
+ return null;
1043
+ const safeCount = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1044
+ ? Math.floor(candidate)
1045
+ : 0);
1046
+ const safeDuration = (candidate) => (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
1047
+ ? candidate
1048
+ : 0);
1049
+ const total = safeCount(progress.total);
1050
+ const rawStage = typeof progress.stage === "string" ? progress.stage : "starting";
1051
+ const stage = ["starting", "reading-transcripts", "building-summary", "classifying-repeated-context", "finalizing-report"].includes(rawStage)
1052
+ ? rawStage
1053
+ : "starting";
1054
+ return {
1055
+ status: "running",
1056
+ scanned: total > 0 ? Math.min(safeCount(progress.scanned), total) : 0,
1057
+ total,
1058
+ stage,
1059
+ label: forensicStageLabel(stage),
1060
+ elapsedMs: safeDuration(progress.elapsedMs),
1061
+ stageElapsedMs: safeDuration(progress.stageElapsedMs),
1062
+ updatedAt: safeDuration(progress.updatedAt) || Date.now(),
1063
+ };
1064
+ }
847
1065
  /**
848
1066
  * Start the persistent localhost bridge used by the connect-device page. It accepts the token,
849
1067
  * serves local Wrapped stats, and holds the /migrate response until cmdLogin has created a cloud
850
1068
  * import session.
851
1069
  */
852
1070
  export function startCallbackServer(opts = {}) {
853
- const timeoutMs = opts.timeoutMs ?? 300_000;
1071
+ const timeoutMs = opts.timeoutMs ?? 15 * 60_000;
1072
+ const approvalTimeoutLabel = timeoutMs >= 60_000
1073
+ ? `${Math.round(timeoutMs / 60_000)} minutes`
1074
+ : `${Math.ceil(timeoutMs / 1000)} seconds`;
854
1075
  const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
855
1076
  const expectedNonce = opts.nonce;
1077
+ const scanId = opts.scanId ?? randomUUID();
856
1078
  return new Promise((resolveOuter, rejectOuter) => {
857
1079
  const onToken = deferred();
858
1080
  const decision = deferred();
@@ -868,6 +1090,10 @@ export function startCallbackServer(opts = {}) {
868
1090
  let timer;
869
1091
  let closed = false;
870
1092
  let server;
1093
+ // The browser setup page holds a keep-alive socket (and polls /progress). server.close() only stops
1094
+ // accepting NEW connections and waits for existing ones to end — so without destroying these the
1095
+ // handle never releases and the CLI hangs after migration. Track live sockets and kill them on close.
1096
+ const sockets = new Set();
871
1097
  const checkNonce = (nonce) => !expectedNonce || nonce === expectedNonce;
872
1098
  const text = (res, status, body = "") => res.writeHead(status, { "Content-Type": "text/plain" }).end(body);
873
1099
  const json = (res, status, body) => res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
@@ -879,6 +1105,9 @@ export function startCallbackServer(opts = {}) {
879
1105
  return;
880
1106
  closed = true;
881
1107
  server.close();
1108
+ for (const socket of sockets)
1109
+ socket.destroy();
1110
+ sockets.clear();
882
1111
  };
883
1112
  const armTimeout = () => {
884
1113
  if (timer)
@@ -886,7 +1115,7 @@ export function startCallbackServer(opts = {}) {
886
1115
  const waitMs = onToken.settled() ? dashboardTimeoutMs : timeoutMs;
887
1116
  timer = setTimeout(() => {
888
1117
  if (!onToken.settled()) {
889
- onToken.reject(new Error("timed out waiting for browser approval"));
1118
+ onToken.reject(new Error(`browser approval did not finish in ${approvalTimeoutLabel}`));
890
1119
  }
891
1120
  else if (!decision.settled()) {
892
1121
  decision.resolve("timeout");
@@ -900,6 +1129,7 @@ export function startCallbackServer(opts = {}) {
900
1129
  return void text(res, 403, "bad nonce");
901
1130
  if (!token)
902
1131
  return void text(res, 400, "missing token");
1132
+ console.log(`[${new Date().toISOString()}] Browser approval callback received.`);
903
1133
  const firstToken = !onToken.settled();
904
1134
  connected = true;
905
1135
  const setupPath = `/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1`;
@@ -939,10 +1169,21 @@ export function startCallbackServer(opts = {}) {
939
1169
  return;
940
1170
  }
941
1171
  if (route === "/setup" && req.method === "GET") {
1172
+ res.setHeader("Cache-Control", "no-store");
1173
+ if (!checkNonce(url.searchParams.get("nonce") || undefined))
1174
+ return void text(res, 403, "bad nonce");
1175
+ const hasPreview = url.searchParams.has("preview");
1176
+ if (hasPreview && opts.allowPreview !== true) {
1177
+ return void text(res, 403, "Design preview is disabled. No sample data was shown.");
1178
+ }
1179
+ const previewState = hasPreview ? parseSetupPreviewState(url.searchParams.get("preview")) : null;
1180
+ if (hasPreview && !previewState) {
1181
+ return void text(res, 400, "Unknown setup preview state.");
1182
+ }
942
1183
  res.writeHead(200, {
943
1184
  "Content-Type": "text/html; charset=utf-8",
944
1185
  "Cache-Control": "no-store",
945
- }).end(renderSetupPage());
1186
+ }).end(renderSetupPage(previewState ? { previewState } : undefined));
946
1187
  return;
947
1188
  }
948
1189
  if (route === "/config" && req.method === "GET") {
@@ -969,6 +1210,21 @@ export function startCallbackServer(opts = {}) {
969
1210
  json(res, result.ok ? 200 : 501, result);
970
1211
  return;
971
1212
  }
1213
+ if (route === "/open-session" && req.method === "POST") {
1214
+ let body;
1215
+ try {
1216
+ body = await readJsonBody(req);
1217
+ }
1218
+ catch {
1219
+ text(res, 400, "bad json");
1220
+ return;
1221
+ }
1222
+ if (!checkNonce(asString(body.nonce)))
1223
+ return void text(res, 403, "bad nonce");
1224
+ const result = openExistingAgentSession(asString(body.source) || "", asString(body.sessionId) || "");
1225
+ json(res, result.ok ? 200 : 400, result);
1226
+ return;
1227
+ }
972
1228
  if (route === "/callback" && req.method === "GET") {
973
1229
  handleCallback(res, url.searchParams.get("token") || undefined, url.searchParams.get("key") || undefined, url.searchParams.get("nonce") || undefined);
974
1230
  return;
@@ -996,15 +1252,99 @@ export function startCallbackServer(opts = {}) {
996
1252
  }
997
1253
  if (route === "/report" && req.method === "GET") {
998
1254
  // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
1255
+ res.setHeader("Cache-Control", "no-store");
999
1256
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
1000
1257
  return void text(res, 403, "bad nonce");
1001
- const payload = opts.getReport ? opts.getReport() : null;
1258
+ let payload;
1259
+ try {
1260
+ payload = opts.getReport ? opts.getReport() : null;
1261
+ }
1262
+ catch {
1263
+ return void json(res, 500, {
1264
+ schemaVersion: 1,
1265
+ kind: "failed",
1266
+ mode: "production",
1267
+ scanId,
1268
+ error: {
1269
+ code: "REPORT_STATE_UNAVAILABLE",
1270
+ message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
1271
+ },
1272
+ });
1273
+ }
1002
1274
  if (payload == null) {
1003
1275
  // 202 carries scan progress so the page can show a live "scanned N/total" indicator.
1004
- const prog = opts.getReportProgress ? opts.getReportProgress() : { scanned: 0, total: 0 };
1005
- return void res.writeHead(202, { "Content-Type": "application/json" }).end(JSON.stringify(prog));
1276
+ let prog;
1277
+ try {
1278
+ prog = opts.getReportProgress ? opts.getReportProgress() : {
1279
+ status: "running",
1280
+ scanned: 0,
1281
+ total: 0,
1282
+ stage: "starting",
1283
+ label: "Starting local scan",
1284
+ elapsedMs: 0,
1285
+ stageElapsedMs: 0,
1286
+ updatedAt: Date.now(),
1287
+ };
1288
+ }
1289
+ catch {
1290
+ return void json(res, 500, {
1291
+ schemaVersion: 1,
1292
+ kind: "failed",
1293
+ mode: "production",
1294
+ scanId,
1295
+ error: {
1296
+ code: "REPORT_STATE_UNAVAILABLE",
1297
+ message: "EchoMem could not read the local scan state. No backup data was substituted. Rerun setup to retry.",
1298
+ },
1299
+ });
1300
+ }
1301
+ if (prog && typeof prog === "object" && prog.status === "failed") {
1302
+ return void json(res, 500, {
1303
+ schemaVersion: 1,
1304
+ kind: "failed",
1305
+ mode: "production",
1306
+ scanId,
1307
+ error: safeForensicError(prog.error),
1308
+ });
1309
+ }
1310
+ const publicProgress = publicRunningForensicProgress(prog);
1311
+ if (!publicProgress) {
1312
+ return void json(res, 500, {
1313
+ schemaVersion: 1,
1314
+ kind: "failed",
1315
+ mode: "production",
1316
+ scanId,
1317
+ error: {
1318
+ code: "REPORT_STATE_INVALID",
1319
+ message: "EchoMem received an invalid local scan state. No backup data was substituted. Rerun setup to retry.",
1320
+ },
1321
+ });
1322
+ }
1323
+ return void json(res, 202, {
1324
+ schemaVersion: 1,
1325
+ kind: "scanning",
1326
+ mode: "production",
1327
+ scanId,
1328
+ progress: publicProgress,
1329
+ });
1006
1330
  }
1007
- json(res, 200, payload);
1331
+ const validation = validateForensicReportForSetup(payload);
1332
+ if (!validation.ok) {
1333
+ return void json(res, 500, {
1334
+ schemaVersion: 1,
1335
+ kind: "failed",
1336
+ mode: "production",
1337
+ scanId,
1338
+ error: { code: validation.code, message: validation.message },
1339
+ });
1340
+ }
1341
+ json(res, 200, {
1342
+ schemaVersion: 1,
1343
+ kind: validation.kind,
1344
+ mode: "production",
1345
+ scanId,
1346
+ report: validation.report,
1347
+ });
1008
1348
  return;
1009
1349
  }
1010
1350
  if (route === "/progress" && req.method === "GET") {
@@ -1091,9 +1431,13 @@ export function startCallbackServer(opts = {}) {
1091
1431
  text(res, 500, e instanceof Error ? e.message : String(e));
1092
1432
  });
1093
1433
  });
1434
+ server.on("connection", (socket) => {
1435
+ sockets.add(socket);
1436
+ socket.on("close", () => sockets.delete(socket));
1437
+ });
1094
1438
  armTimeout();
1095
1439
  server.on("error", (e) => rejectOuter(e));
1096
- server.listen(0, "127.0.0.1", () => {
1440
+ server.listen(opts.port ?? 0, "127.0.0.1", () => {
1097
1441
  const addr = server.address();
1098
1442
  const port = typeof addr === "object" && addr ? addr.port : 0;
1099
1443
  resolveOuter({
@@ -1300,13 +1644,15 @@ async function cmdInit(flags) {
1300
1644
  await cmdSetupHud(flags);
1301
1645
  // 3. Start onboarding — opens the browser dashboard (scan → connect → extraction) and waits there.
1302
1646
  console.log("");
1303
- if (!flags["skip-login"] && !flags["no-login"])
1304
- await cmdLogin(flags);
1647
+ if (!flags["skip-login"] && !flags["no-login"] && !await cmdLogin(flags)) {
1648
+ console.log("\nEchoMem is configured, but this device was not connected. Restart EchoMem and use the new page it opens.");
1649
+ return;
1650
+ }
1305
1651
  console.log("");
1306
1652
  console.log("🎉 EchoMem is ready.");
1307
1653
  console.log(" • MCP memory is configured for every coding agent installed on this machine.");
1308
1654
  if (!flags["no-hud"]) {
1309
- console.log(' • The context HUD is running (top-right). Right-click it → "Open at login" to keep it,');
1655
+ console.log(' • The context HUD is running (top-right). Right-click it → "Show after restart" to keep it,');
1310
1656
  console.log(' or just tell your agent "open the EchoMem HUD" anytime (it runs: echomem-hud app).');
1311
1657
  }
1312
1658
  else {
@@ -1385,9 +1731,21 @@ async function cmdSetupHud(flags) {
1385
1731
  }
1386
1732
  function resolveHudCliPath() {
1387
1733
  const entry = fs.realpathSync(process.argv[1] || "");
1734
+ const compiledEntry = compiledDistPathForSource(entry);
1735
+ if (compiledEntry) {
1736
+ const compiledHud = path.join(path.dirname(compiledEntry), "hud", "cli.js");
1737
+ if (fs.existsSync(compiledHud))
1738
+ return compiledHud;
1739
+ }
1388
1740
  const base = path.dirname(entry);
1389
1741
  const candidate = path.join(base, "hud", "cli.js");
1390
- return fs.existsSync(candidate) ? candidate : path.join(base, "hud", "cli.ts");
1742
+ if (fs.existsSync(candidate))
1743
+ return candidate;
1744
+ const sourceCandidate = path.join(base, "hud", "cli.ts");
1745
+ if (fs.existsSync(sourceCandidate)) {
1746
+ throw new Error("The local HUD CLI is not built. Run npm --prefix packages/mcp-server run build and retry.");
1747
+ }
1748
+ return candidate;
1391
1749
  }
1392
1750
  function parseHudClient(value) {
1393
1751
  return value === "codex" || value === "claude-code" || value === "claude-desktop" || value === "both" || value === "auto"
@@ -1404,16 +1762,39 @@ async function cmdLogin(flags) {
1404
1762
  });
1405
1763
  if (!ok)
1406
1764
  process.exitCode = 1;
1407
- return;
1765
+ return ok;
1408
1766
  }
1409
1767
  // Browser path: open a localhost dashboard. It briefly leaves for hosted auth, then returns here
1410
1768
  // after the web page has delivered the token+key to the callback. The nonce gates every local route.
1411
1769
  console.log("Opening your browser to approve this device…");
1412
- const nonce = randomUUID();
1770
+ const devPortRaw = typeof flags["dev-port"] === "string" ? Number(flags["dev-port"]) : undefined;
1771
+ if (devPortRaw !== undefined && (!Number.isInteger(devPortRaw) || devPortRaw < 1024 || devPortRaw > 65535)) {
1772
+ throw new Error("--dev-port must be an integer between 1024 and 65535");
1773
+ }
1774
+ const devNonce = typeof flags["dev-nonce"] === "string" ? flags["dev-nonce"].trim() : undefined;
1775
+ if (devNonce && devPortRaw === undefined)
1776
+ throw new Error("--dev-nonce requires --dev-port");
1777
+ if (devNonce && !/^[A-Za-z0-9-]{16,128}$/.test(devNonce)) {
1778
+ throw new Error("--dev-nonce must contain 16-128 letters, numbers, or hyphens");
1779
+ }
1780
+ const nonce = devNonce || randomUUID();
1413
1781
  let stats = null;
1414
1782
  let forensicReport = null;
1415
- let forensicProgress = { scanned: 0, total: 0 };
1783
+ const forensicStartedAt = Date.now();
1784
+ let forensicStageStartedAt = forensicStartedAt;
1785
+ let forensicStage = "starting";
1786
+ let forensicProgress = {
1787
+ status: "running",
1788
+ scanned: 0,
1789
+ total: 0,
1790
+ stage: forensicStage,
1791
+ label: forensicStageLabel(forensicStage),
1792
+ elapsedMs: 0,
1793
+ stageElapsedMs: 0,
1794
+ updatedAt: forensicStartedAt,
1795
+ };
1416
1796
  const srv = await startCallbackServer({
1797
+ port: devPortRaw,
1417
1798
  nonce,
1418
1799
  getStats: () => stats,
1419
1800
  getReport: () => forensicReport,
@@ -1430,15 +1811,45 @@ async function cmdLogin(flags) {
1430
1811
  srv.setAuthUrl(connectUrl, switchAccountUrl.toString());
1431
1812
  openBrowser(localSetupUrl);
1432
1813
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
1814
+ console.log("Waiting for browser approval for up to 15 minutes…");
1433
1815
  // Scan-first: build the local forensic "Context Doctor" report off-thread so the page shows it
1434
1816
  // BEFORE the user connects an account (the scan is local-only; nothing leaves the machine).
1435
- buildForensicReportOffThread((done, total) => {
1436
- forensicProgress = { scanned: done, total };
1817
+ buildForensicReportOffThread((progress) => {
1818
+ const now = Date.now();
1819
+ const nextStage = progress.stage || forensicStage;
1820
+ if (nextStage !== forensicStage) {
1821
+ forensicStage = nextStage;
1822
+ forensicStageStartedAt = now;
1823
+ console.log(`Local scan: ${forensicStageLabel(forensicStage)}…`);
1824
+ }
1825
+ forensicProgress = {
1826
+ status: "running",
1827
+ scanned: progress.done,
1828
+ total: progress.total,
1829
+ stage: forensicStage,
1830
+ label: forensicStageLabel(forensicStage),
1831
+ detail: progress.detail,
1832
+ elapsedMs: now - forensicStartedAt,
1833
+ stageElapsedMs: now - forensicStageStartedAt,
1834
+ updatedAt: now,
1835
+ };
1437
1836
  })
1438
1837
  .then((r) => {
1439
1838
  forensicReport = r;
1440
1839
  })
1441
1840
  .catch((e) => {
1841
+ const now = Date.now();
1842
+ forensicProgress = {
1843
+ status: "failed",
1844
+ scanned: forensicProgress.scanned,
1845
+ total: forensicProgress.total,
1846
+ stage: "failed",
1847
+ label: "Local scan failed",
1848
+ elapsedMs: now - forensicStartedAt,
1849
+ stageElapsedMs: now - forensicStageStartedAt,
1850
+ updatedAt: now,
1851
+ error: safeForensicError(e),
1852
+ };
1442
1853
  console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
1443
1854
  });
1444
1855
  let token;
@@ -1448,14 +1859,14 @@ async function cmdLogin(flags) {
1448
1859
  }
1449
1860
  catch (e) {
1450
1861
  srv.close();
1451
- console.error(`❌ ${e?.message || e}. You can instead run: echomem-mcp login --token ec_… [--passphrase <vault pass>]`);
1862
+ console.error(`❌ ${e?.message || e}. Restart EchoMem and use the new page it opens.`);
1452
1863
  process.exitCode = 1;
1453
- return;
1864
+ return false;
1454
1865
  }
1455
1866
  if (!await verifyAndPrint({ token, key })) {
1456
1867
  srv.close();
1457
1868
  process.exitCode = 1;
1458
- return;
1869
+ return false;
1459
1870
  }
1460
1871
  let disc = null;
1461
1872
  let exactDiscovery = Promise.resolve(null);
@@ -1788,7 +2199,7 @@ async function cmdLogin(flags) {
1788
2199
  sendMigrate({ error: "IMPORT_START_FAILED", message: "Local session discovery did not finish." }, 500);
1789
2200
  srv.close();
1790
2201
  process.exitCode = 1;
1791
- return;
2202
+ return true;
1792
2203
  }
1793
2204
  activeJobCount = exact.pending.length;
1794
2205
  const updateProgress = (patch) => {
@@ -1820,7 +2231,7 @@ async function cmdLogin(flags) {
1820
2231
  sendMigrate({ error: "NO_PENDING_SESSIONS" }, 409);
1821
2232
  srv.close();
1822
2233
  console.log("Setup complete — no unprocessed local conversations to extract.");
1823
- return;
2234
+ return true;
1824
2235
  }
1825
2236
  updateProgress({ status: "starting", running: 0, queued: exact.pending.length, latest: "Creating import session." });
1826
2237
  // Plan caps limit how many conversations one import session accepts (IMPORT_LIMIT_EXCEEDED →
@@ -1934,6 +2345,7 @@ async function cmdLogin(flags) {
1934
2345
  console.log("Setup complete — run `echomem-mcp migrate` later to back-fill your history.");
1935
2346
  srv.close();
1936
2347
  }
2348
+ return true;
1937
2349
  }
1938
2350
  async function cmdUnlock(flags) {
1939
2351
  const store = new KeyStore();