@echomem/mcp 1.4.40 → 1.4.42

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.
@@ -35,10 +35,11 @@ export const SAVED_MEMORY_RECEIPT_INSTRUCTION = 'After save_conversation succeed
35
35
  export const MCP_SERVER_INSTRUCTIONS = [
36
36
  `${MCP_PACKAGE_DESCRIPTION} (${MCP_PACKAGE_LABEL}).`,
37
37
  `If stale, ${MCP_UPDATE_INSTRUCTION}; restart the MCP session afterward. Updates never delay startup.`,
38
+ "A local SessionStart hook binds supported conversations to their real provider sessions; use bind_source_session only as fallback.",
38
39
  "Before re-deriving prior decisions or preferences, use search_memories.",
39
40
  `Before the final response for a durable decision, implementation, fix, commit, passing verification, release, or milestone, call save_conversation. Skip secrets and trivial work. If the encrypted vault is locked, tell the user to ${MCP_VAULT_UNLOCK_INSTRUCTION}.`,
40
41
  "After a successful save, show every memory created by that call in a compact EchoMem saved: list with canonical links; this is separate from \"EchoMem sources:\".",
41
- "For company groups, call request_group_session_sharing near conversation start or after a qualifying save, relay its exact text question, and call set_group_session_sharing only after an explicit Yes or No. Omit groupSharingScopeId only on the first call, then reuse the returned scope only in this conversation. Each group needs an explicit choice; silence stays unset. Never infer consent. Flagged memories stay private.",
42
+ "For company groups, request sharing near conversation start or after a save; relay its exact question and set only after explicit Yes or No. Reuse the returned scope only in this conversation. Each group needs its own choice; silence means unset. Flagged memories stay private.",
42
43
  "EchoMem credential identity is authoritative over Claude profiles, host accounts, git identity, or inference. Use get_group_context before group-orientation answers and never re-filter owners returned by search_others_memories.",
43
44
  "When a final answer materially uses teammate or friend memories, call record_memory_citations with only the exact used Memory IDs and a unique per-answer receiptId.",
44
45
  "End memory-informed answers with a compact EchoMem sources: list. Use each memory key as the label and https://echoknows.com/memory/<memory-id> as its canonical link.",
package/dist/setup.js CHANGED
@@ -31,7 +31,7 @@ import { syncCodexUsage } from "./codex-sync.js";
31
31
  import { renderSetupPage } from "./setup-page.js";
32
32
  import { parseSetupPreviewState } from "./setup-preview.js";
33
33
  import { repoLabel, validateForensicReportForSetup } from "./forensics.js";
34
- import { installSaveCheckpointHooks } from "./hud/hooks.js";
34
+ import { installSaveCheckpointHooks, installSourceSessionHooks } from "./hud/hooks.js";
35
35
  import { MCP_PACKAGE_LABEL, MCP_PACKAGE_NAME, MCP_PACKAGE_VERSION, MCP_UPDATE_ALL_COMMAND, MCP_UPDATE_COMMAND } from "./package-metadata.js";
36
36
  import { checkLatestUpdateStatus, compareSemver, readCachedUpdateStatus } from "./update-check.js";
37
37
  import { installHeadlessRuntimeSync, readHeadlessRuntimeInstallation, } from "./headless-runtime.js";
@@ -215,7 +215,7 @@ export function knownClients() {
215
215
  { id: "cursor", label: "Cursor", kind: "json", configPath: home(".cursor", "mcp.json") },
216
216
  { id: "windsurf", label: "Windsurf", kind: "json", configPath: home(".codeium", "windsurf", "mcp_config.json") },
217
217
  { id: "claude-desktop", label: "Claude Desktop", kind: "json", configPath: path.join(appSupport, "Claude", "claude_desktop_config.json") },
218
- { id: "claude-code", label: "Claude Code", kind: "snippet", note: "run: claude mcp add-json echomem '<entry>' (or add to .mcp.json)" },
218
+ { id: "claude-code", label: "Claude Code", kind: "snippet", note: "run: claude mcp add-json -s user echomem '<entry>' (or add to .mcp.json)" },
219
219
  { id: "codex", label: "Codex", kind: "command", detectDir: codexHome(), configPath: path.join(codexHome(), "config.toml"), note: "add to ~/.codex/config.toml under [mcp_servers.echomem]" },
220
220
  ];
221
221
  }
@@ -356,6 +356,7 @@ function echomemGuidanceBlock() {
356
356
  "## Memory (EchoMem)",
357
357
  "EchoMem is your long-term memory across all coding sessions and tools.",
358
358
  "- Use EchoMem's `echomem-*` skills and MCP tools as the default memory provider. Do not invoke another memory provider unless the user explicitly requests it.",
359
+ "- EchoMem's local SessionStart hook automatically binds each new Codex or Claude Code conversation to its real source-session ID. Use `bind_source_session` with a fresh UUID only as a compatibility fallback when the hook is unavailable or reports that the session is unbound.",
359
360
  "- Before re-deriving past decisions, project context, or anything the user likely settled before: call `search_memories` first.",
360
361
  '- If the final user-facing answer materially relies on one or more EchoMem memories, end it with a compact `EchoMem sources:` list containing only the memories actually used. Link each memory key to its canonical `https://echoknows.com/memory/<memory-id>` URL. Do not cite memories that were merely retrieved, and omit the section when no memory informed the answer.',
361
362
  "- Before the final response for a task that produced a durable decision, confirmed preference, implementation, resolved bug, commit, passing verification, release, or project milestone: call `save_conversation`. This private checkpoint does not require a separate user request. Do not save secrets, credentials, incidental chatter, or trivial read-only work. If a qualifying save fails because the encrypted vault is locked, tell the user to open Echo Desktop and unlock the vault there; on a headless system, use `echomem-mcp unlock`. Never silently skip it.",
@@ -415,13 +416,70 @@ export function writeJsonClientConfig(configPath, entry) {
415
416
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
416
417
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
417
418
  }
418
- export function writeClaudeCodeConfig(entry) {
419
- // EchoMem is a memory server that should load in EVERY Claude Code project, so it belongs at
420
- // `user` scope (~/.claude.json, all projects) rather than `local` scope (the current project only).
421
- const addArguments = ["mcp", "add-json", "-s", "user", "echomem", JSON.stringify(entry)];
422
- const removeFromScope = (scope) => {
419
+ function readClaudeCodeConfigFile(configPath) {
420
+ try {
421
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
422
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
423
+ ? parsed
424
+ : {};
425
+ }
426
+ catch {
427
+ return {};
428
+ }
429
+ }
430
+ function echoMemEntryFromServers(value) {
431
+ if (!value || typeof value !== "object" || Array.isArray(value))
432
+ return undefined;
433
+ const entry = value.echomem;
434
+ return entry && typeof entry === "object" && !Array.isArray(entry)
435
+ ? entry
436
+ : undefined;
437
+ }
438
+ function claudeEntriesMatch(actual, expected) {
439
+ if (!actual || actual.command !== expected.command)
440
+ return false;
441
+ const actualArgs = Array.isArray(actual.args) ? actual.args : [];
442
+ const expectedArgs = Array.isArray(expected.args) ? expected.args : [];
443
+ if (actualArgs.length !== expectedArgs.length || actualArgs.some((value, index) => value !== expectedArgs[index])) {
444
+ return false;
445
+ }
446
+ const expectedEnv = expected.env;
447
+ if (!expectedEnv || typeof expectedEnv !== "object" || Array.isArray(expectedEnv))
448
+ return true;
449
+ const actualEnv = actual.env;
450
+ if (!actualEnv || typeof actualEnv !== "object" || Array.isArray(actualEnv))
451
+ return false;
452
+ return Object.entries(expectedEnv).every(([key, value]) => actualEnv[key] === value);
453
+ }
454
+ function claudeCodeLocalEchoMemProjects(configPath) {
455
+ const projects = readClaudeCodeConfigFile(configPath).projects;
456
+ if (!projects || typeof projects !== "object" || Array.isArray(projects))
457
+ return [];
458
+ return Object.entries(projects)
459
+ .filter(([, value]) => {
460
+ if (!value || typeof value !== "object" || Array.isArray(value))
461
+ return false;
462
+ return Boolean(echoMemEntryFromServers(value.mcpServers));
463
+ })
464
+ .map(([projectPath]) => projectPath)
465
+ .sort();
466
+ }
467
+ export function writeClaudeCodeConfig(entry, options = {}) {
468
+ // EchoMem belongs at user scope so every Claude Code project resolves the same durable runtime.
469
+ // Older CLI versions wrote local/project entries, which take precedence over user scope and can
470
+ // keep launching a deleted npm cache or stale runtime. Migrate those only after user scope is safe.
471
+ const configPath = options.configPath ?? home(".claude.json");
472
+ const emptyResult = () => ({
473
+ state: "unavailable",
474
+ removedLocalProjects: [],
475
+ skippedLocalProjects: [],
476
+ failedLocalProjects: [],
477
+ restoredPreviousUserEntry: false,
478
+ });
479
+ const runClaude = (args, cwd) => {
423
480
  try {
424
- execFileSync("claude", ["mcp", "remove", "echomem", "-s", scope], {
481
+ execFileSync("claude", args, {
482
+ cwd,
425
483
  encoding: "utf8",
426
484
  stdio: ["ignore", "pipe", "pipe"],
427
485
  timeout: 10000,
@@ -432,39 +490,61 @@ export function writeClaudeCodeConfig(entry) {
432
490
  return false;
433
491
  }
434
492
  };
435
- // Older builds installed EchoMem at `local` scope. Left in place it would shadow the user-scoped
436
- // entry and keep launching the stale command, so drop it first. Best-effort: `local` is per-project,
437
- // so this only clears the directory setup runs from — a no-op (ignored) when nothing is there.
438
- removeFromScope("local");
439
- try {
440
- execFileSync("claude", addArguments, {
441
- encoding: "utf8",
442
- stdio: ["ignore", "pipe", "pipe"],
443
- timeout: 10000,
444
- });
445
- return "wrote";
446
- }
447
- catch (error) {
448
- const stderr = error.stderr;
449
- const detail = Buffer.isBuffer(stderr) ? stderr.toString("utf8") : String(stderr ?? "");
450
- if (!detail.includes("already exists"))
451
- return "unavailable";
452
- }
453
- // Claude Code's CLI will not replace a same-name server. Once the replacement entry is fully
454
- // constructed, remove only EchoMem and immediately re-add it; sibling MCP servers remain.
455
- try {
456
- if (!removeFromScope("user"))
457
- return "unavailable";
458
- execFileSync("claude", addArguments, {
459
- encoding: "utf8",
460
- stdio: ["ignore", "pipe", "pipe"],
461
- timeout: 10000,
462
- });
463
- return "wrote";
493
+ const addUser = (value) => runClaude([
494
+ "mcp", "add-json", "-s", "user", "echomem", JSON.stringify(value),
495
+ ]);
496
+ const removeUser = () => runClaude(["mcp", "remove", "echomem", "-s", "user"]);
497
+ const before = readClaudeCodeConfigFile(configPath);
498
+ const previousUserEntry = echoMemEntryFromServers(before.mcpServers);
499
+ let restoredPreviousUserEntry = false;
500
+ // Avoid interrupting active/new sessions when the correct global entry is already installed.
501
+ if (!claudeEntriesMatch(previousUserEntry, entry)) {
502
+ if (previousUserEntry && !removeUser())
503
+ return emptyResult();
504
+ if (!addUser(entry)) {
505
+ if (previousUserEntry)
506
+ restoredPreviousUserEntry = addUser(previousUserEntry);
507
+ return { ...emptyResult(), restoredPreviousUserEntry };
508
+ }
464
509
  }
465
- catch {
466
- return "unavailable";
510
+ const installedUserEntry = echoMemEntryFromServers(readClaudeCodeConfigFile(configPath).mcpServers);
511
+ if (!claudeEntriesMatch(installedUserEntry, entry)) {
512
+ return { ...emptyResult(), restoredPreviousUserEntry };
513
+ }
514
+ const removedLocalProjects = [];
515
+ const skippedLocalProjects = [];
516
+ const failedLocalProjects = [];
517
+ for (const projectPath of claudeCodeLocalEchoMemProjects(configPath)) {
518
+ // A deleted directory cannot currently shadow user scope. Do not recreate it or hand-edit
519
+ // ~/.claude.json, which also contains Claude account/session state.
520
+ if (!fs.existsSync(projectPath)) {
521
+ skippedLocalProjects.push(projectPath);
522
+ continue;
523
+ }
524
+ let projectCwd = projectPath;
525
+ try {
526
+ projectCwd = fs.realpathSync(projectPath);
527
+ }
528
+ catch {
529
+ /* The existence check above already established the safe fallback path. */
530
+ }
531
+ if (runClaude(["mcp", "remove", "echomem", "-s", "local"], projectCwd)) {
532
+ removedLocalProjects.push(projectPath);
533
+ }
534
+ else {
535
+ failedLocalProjects.push(projectPath);
536
+ }
467
537
  }
538
+ const remainingActiveProjects = claudeCodeLocalEchoMemProjects(configPath)
539
+ .filter((projectPath) => fs.existsSync(projectPath));
540
+ const unresolved = [...new Set([...failedLocalProjects, ...remainingActiveProjects])].sort();
541
+ return {
542
+ state: unresolved.length > 0 ? "needs-repair" : "wrote",
543
+ removedLocalProjects,
544
+ skippedLocalProjects,
545
+ failedLocalProjects: unresolved,
546
+ restoredPreviousUserEntry,
547
+ };
468
548
  }
469
549
  function readJsonClientEntry(configPath) {
470
550
  try {
@@ -2667,6 +2747,7 @@ async function cmdSetup(flags) {
2667
2747
  const entry = buildServerEntry({ devEntryPath: typeof flags.dev === "string" ? flags.dev : undefined });
2668
2748
  const requested = typeof flags.client === "string" ? flags.client : undefined;
2669
2749
  const targets = selectSetupTargets(requested, Boolean(flags.all));
2750
+ const configurationFailures = [];
2670
2751
  if (targets.length === 0) {
2671
2752
  console.log("No client auto-detected. Add this MCP server entry manually:\n");
2672
2753
  console.log(JSON.stringify({ echomem: entry }, null, 2));
@@ -2687,23 +2768,39 @@ async function cmdSetup(flags) {
2687
2768
  }
2688
2769
  else {
2689
2770
  const result = c.id === "claude-code" ? writeClaudeCodeConfig(entry) : "unavailable";
2690
- if (result === "wrote") {
2771
+ if (result !== "unavailable" && result.state === "wrote") {
2691
2772
  console.log(`✅ Wrote EchoMem MCP entry to ${c.label} via \`claude mcp add-json\` — start a new Claude Code session to load it.`);
2773
+ if (result.removedLocalProjects.length > 0) {
2774
+ console.log(`✅ Removed ${result.removedLocalProjects.length} stale Claude Code project-local EchoMem ${result.removedLocalProjects.length === 1 ? "entry" : "entries"}.`);
2775
+ }
2776
+ if (result.skippedLocalProjects.length > 0) {
2777
+ console.log(`ℹ️ Ignored ${result.skippedLocalProjects.length} EchoMem local ${result.skippedLocalProjects.length === 1 ? "entry" : "entries"} for deleted project directories; they cannot shadow the user entry.`);
2778
+ }
2692
2779
  }
2693
2780
  else {
2694
- console.log(`ℹ️ ${c.label}: ${c.note}\n entry: ${JSON.stringify(entry)}`);
2781
+ const failedProjects = result === "unavailable" ? [] : result.failedLocalProjects;
2782
+ configurationFailures.push(failedProjects.length > 0
2783
+ ? `${c.label} still has project-local EchoMem overrides in: ${failedProjects.join(", ")}`
2784
+ : `${c.label} user-scoped EchoMem entry could not be verified`);
2695
2785
  }
2696
2786
  }
2697
2787
  }
2698
2788
  }
2789
+ if (configurationFailures.length > 0) {
2790
+ throw new Error([
2791
+ "EchoMem MCP configuration is incomplete; onboarding was stopped before login/import.",
2792
+ ...configurationFailures.map((failure) => `- ${failure}`),
2793
+ `Retry with: ${MCP_UPDATE_COMMAND} --client claude-code`,
2794
+ ].join("\n"));
2795
+ }
2699
2796
  if (!flags["no-agents-md"]) {
2700
2797
  writeMemoryGuidanceForTargets(targets);
2701
2798
  }
2702
2799
  if (!flags["no-codex-skills"]) {
2703
2800
  writeCodexSkillsForTargets(targets);
2704
2801
  }
2705
- if (flags["install-save-hooks"]) {
2706
- writeSaveCheckpointHooksForTargets(targets);
2802
+ if (flags["no-save-hooks"] !== true) {
2803
+ writeLifecycleHooksForTargets(targets);
2707
2804
  }
2708
2805
  console.log("");
2709
2806
  if (flags["skip-login"] || flags["no-login"]) {
@@ -2793,7 +2890,7 @@ function writeCodexSkillsForTargets(targets) {
2793
2890
  console.log(`ℹ️ Could not install EchoMem Codex skills: ${error instanceof Error ? error.message : String(error)}`);
2794
2891
  }
2795
2892
  }
2796
- function writeSaveCheckpointHooksForTargets(targets) {
2893
+ function writeLifecycleHooksForTargets(targets) {
2797
2894
  const clients = new Set();
2798
2895
  if (targets.some((target) => target.id === "codex"))
2799
2896
  clients.add("codex");
@@ -2804,8 +2901,10 @@ function writeSaveCheckpointHooksForTargets(targets) {
2804
2901
  return;
2805
2902
  }
2806
2903
  const mode = clients.size === 2 ? "both" : [...clients][0];
2807
- const paths = installSaveCheckpointHooks(mode);
2808
- console.log(`✅ Installed EchoMem private-save checkpoint hooks:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
2904
+ const sourcePaths = installSourceSessionHooks(mode);
2905
+ const savePaths = installSaveCheckpointHooks(mode);
2906
+ const paths = [...new Set([...sourcePaths, ...savePaths])];
2907
+ console.log(`✅ Installed EchoMem source-session and private-save hooks:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
2809
2908
  if (clients.has("codex")) {
2810
2909
  console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
2811
2910
  }
@@ -3810,7 +3909,7 @@ Manual / headless:
3810
3909
  ${MCP_UPDATE_ALL_COMMAND} # one-shot latest update for detected clients, no browser login
3811
3910
  ${MCP_UPDATE_COMMAND} --client codex # update one client only
3812
3911
  echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
3813
- echomem-mcp setup --install-save-hooks --all Install proactive private-save completion checks
3912
+ echomem-mcp setup --install-save-hooks --all Install source-session + private-save lifecycle hooks
3814
3913
  echomem-mcp sync-usage --days 7 --limit 50 --dry-run
3815
3914
 
3816
3915
  Current bridge version: ${MCP_PACKAGE_VERSION}
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import axios from "axios";
4
+ import { KeyStore } from "./keystore.js";
5
+ import { SOURCE_SESSION_HOOK_EVIDENCE, verifiedSourceSession, } from "./source-session.js";
6
+ const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
7
+ const MAX_HEAD_BYTES = 256 * 1024;
8
+ function isRecord(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+ function transcriptHead(file) {
12
+ const descriptor = fs.openSync(file, "r");
13
+ try {
14
+ const size = Math.min(fs.fstatSync(descriptor).size, MAX_HEAD_BYTES);
15
+ const buffer = Buffer.allocUnsafe(size);
16
+ const read = fs.readSync(descriptor, buffer, 0, size, 0);
17
+ return buffer.toString("utf8", 0, read);
18
+ }
19
+ finally {
20
+ fs.closeSync(descriptor);
21
+ }
22
+ }
23
+ function codexSessionFromTranscript(file) {
24
+ for (const line of transcriptHead(file).split(/\r?\n/)) {
25
+ try {
26
+ const row = JSON.parse(line);
27
+ if (!isRecord(row) || row.type !== "session_meta" || !isRecord(row.payload))
28
+ continue;
29
+ const id = typeof row.payload.id === "string" ? row.payload.id : "";
30
+ if (!id)
31
+ return null;
32
+ const source = row.payload.source;
33
+ const isSubagent = typeof row.payload.parent_thread_id === "string"
34
+ || (isRecord(source) && Object.hasOwn(source, "subagent"));
35
+ return { id, isSubagent };
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ function coworkSessionFromPath(file) {
44
+ return file.split(path.sep).find((part) => /^local_[A-Za-z0-9_-]{1,194}$/.test(part)) ?? null;
45
+ }
46
+ export function resolveSourceSessionFromHookPayload(payload) {
47
+ const requestedSessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
48
+ const transcriptPath = typeof payload.transcript_path === "string" ? payload.transcript_path.trim() : "";
49
+ if (!requestedSessionId || !transcriptPath || !path.isAbsolute(transcriptPath))
50
+ return null;
51
+ let file;
52
+ try {
53
+ file = fs.realpathSync(transcriptPath);
54
+ if (!fs.statSync(file).isFile())
55
+ return null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ const coworkSession = coworkSessionFromPath(file);
61
+ if (coworkSession) {
62
+ return verifiedSourceSession("cowork", coworkSession, SOURCE_SESSION_HOOK_EVIDENCE);
63
+ }
64
+ const codexSession = codexSessionFromTranscript(file);
65
+ if (codexSession) {
66
+ if (codexSession.isSubagent || codexSession.id.toLowerCase() !== requestedSessionId.toLowerCase()) {
67
+ return null;
68
+ }
69
+ return verifiedSourceSession("codex", codexSession.id, SOURCE_SESSION_HOOK_EVIDENCE);
70
+ }
71
+ const filenameSession = path.basename(file, path.extname(file));
72
+ if (filenameSession !== requestedSessionId)
73
+ return null;
74
+ return verifiedSourceSession("claude-code", filenameSession, SOURCE_SESSION_HOOK_EVIDENCE);
75
+ }
76
+ async function bindSourceSession(identity) {
77
+ const token = new KeyStore().getToken();
78
+ if (!token)
79
+ return;
80
+ await axios.post(`${API_BASE}/api/extension/source-sessions/bind`, {
81
+ provider: identity.provider,
82
+ providerSessionId: identity.providerSessionId,
83
+ evidence: identity.evidence,
84
+ }, {
85
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
86
+ timeout: 3_500,
87
+ });
88
+ }
89
+ /** SessionStart must never prevent the host conversation from opening. The first MCP call retries. */
90
+ export async function runSourceSessionStartHook(input) {
91
+ try {
92
+ const parsed = JSON.parse(input);
93
+ if (!isRecord(parsed))
94
+ return JSON.stringify({ continue: true });
95
+ const identity = resolveSourceSessionFromHookPayload(parsed);
96
+ if (identity)
97
+ await bindSourceSession(identity);
98
+ }
99
+ catch {
100
+ // Startup binding is best-effort locally; MCP request metadata provides an exact retry path.
101
+ }
102
+ return JSON.stringify({ continue: true });
103
+ }
@@ -0,0 +1,261 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { resolveClaudeProjectsDir, resolveCodexSessionRoots } from "./local-data-paths.js";
5
+ export const SOURCE_SESSION_BINDING_EVIDENCE = "local_jsonl_tool_call";
6
+ export const SOURCE_SESSION_HOOK_EVIDENCE = "local_session_start_hook";
7
+ export const SOURCE_SESSION_MCP_METADATA_EVIDENCE = "local_mcp_session_metadata";
8
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ const UUID_IN_TEXT_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
10
+ const MAX_TAIL_BYTES = 512 * 1024;
11
+ const DEFAULT_MAX_AGE_MS = 15 * 60 * 1000;
12
+ const MAX_RECENT_FILES_PER_PROVIDER = 24;
13
+ function normalizedProviderSessionId(provider, value) {
14
+ const normalized = value.trim();
15
+ if (!normalized || normalized.length > 200 || /\s/.test(normalized)) {
16
+ throw new Error("source-session ID must contain 1-200 non-whitespace characters");
17
+ }
18
+ if (provider === "codex" && !UUID_RE.test(normalized)) {
19
+ throw new Error("Codex source-session ID must be a UUID");
20
+ }
21
+ if (provider === "cowork" && !/^local_[A-Za-z0-9_-]{1,194}$/.test(normalized)) {
22
+ throw new Error("Cowork source-session ID must use the local_ host session identifier");
23
+ }
24
+ return UUID_RE.test(normalized) ? normalized.toLowerCase() : normalized;
25
+ }
26
+ export function verifiedSourceSession(provider, providerSessionId, evidence) {
27
+ const normalized = normalizedProviderSessionId(provider, providerSessionId);
28
+ return {
29
+ provider,
30
+ providerSessionId: normalized,
31
+ canonicalKey: `${provider}:${normalized}`,
32
+ evidence,
33
+ };
34
+ }
35
+ /**
36
+ * Resolve identity from host-owned MCP metadata. Unlike tool arguments, these values are attached by
37
+ * Codex/Claude outside the model-controlled input. The SessionStart hook performs the eager bind;
38
+ * this resolver lets the MCP process independently attach the same identity on its first real call.
39
+ */
40
+ export function resolveSourceSessionFromMcpContext(metadata, options = {}) {
41
+ const host = options.hostPlatform?.toLowerCase().replace(/[^a-z0-9]+/g, "_") ?? "";
42
+ const env = options.env ?? process.env;
43
+ if (host.includes("codex")) {
44
+ const root = isRecord(metadata) ? metadata : {};
45
+ const turn = isRecord(root["x-codex-turn-metadata"])
46
+ ? root["x-codex-turn-metadata"]
47
+ : {};
48
+ const candidate = typeof turn.thread_id === "string"
49
+ ? turn.thread_id
50
+ : typeof turn.session_id === "string"
51
+ ? turn.session_id
52
+ : typeof root.threadId === "string"
53
+ ? root.threadId
54
+ : "";
55
+ return candidate
56
+ ? verifiedSourceSession("codex", candidate, SOURCE_SESSION_MCP_METADATA_EVIDENCE)
57
+ : null;
58
+ }
59
+ if (host.includes("claude_desktop") || host === "claude" || host.includes("cowork")) {
60
+ const candidate = env.CLAUDE_CODE_HOST_SESSION_ID?.trim() ?? "";
61
+ return candidate
62
+ ? verifiedSourceSession("cowork", candidate, SOURCE_SESSION_MCP_METADATA_EVIDENCE)
63
+ : null;
64
+ }
65
+ if (host.includes("claude_code")) {
66
+ const candidate = env.CLAUDE_CODE_SESSION_ID?.trim() ?? "";
67
+ return candidate
68
+ ? verifiedSourceSession("claude-code", candidate, SOURCE_SESSION_MCP_METADATA_EVIDENCE)
69
+ : null;
70
+ }
71
+ return null;
72
+ }
73
+ function isRecord(value) {
74
+ return typeof value === "object" && value !== null && !Array.isArray(value);
75
+ }
76
+ function readableDirectory(candidate) {
77
+ try {
78
+ return fs.statSync(candidate).isDirectory();
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ function coworkRoot() {
85
+ const supportRoot = process.env.CLAUDE_DESKTOP_SUPPORT_DIR?.trim()
86
+ || path.join(os.homedir(), "Library", "Application Support", "Claude");
87
+ return path.join(supportRoot, "local-agent-mode-sessions");
88
+ }
89
+ function defaultRoots() {
90
+ const claudeProjects = resolveClaudeProjectsDir();
91
+ const desktopRoot = coworkRoot();
92
+ return {
93
+ codex: resolveCodexSessionRoots().map((root) => root.path),
94
+ claudeCode: claudeProjects ? [claudeProjects] : [],
95
+ cowork: readableDirectory(desktopRoot) ? [desktopRoot] : [],
96
+ };
97
+ }
98
+ function walkJsonlFiles(roots) {
99
+ const files = [];
100
+ const pending = roots.filter(readableDirectory);
101
+ while (pending.length) {
102
+ const directory = pending.pop();
103
+ let entries;
104
+ try {
105
+ entries = fs.readdirSync(directory, { withFileTypes: true });
106
+ }
107
+ catch {
108
+ continue;
109
+ }
110
+ for (const entry of entries) {
111
+ const target = path.join(directory, entry.name);
112
+ if (entry.isDirectory()) {
113
+ if (entry.name === "subagents" || entry.name === "workflows")
114
+ continue;
115
+ pending.push(target);
116
+ }
117
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
118
+ files.push(target);
119
+ }
120
+ }
121
+ }
122
+ return files;
123
+ }
124
+ function recentFiles(roots, nowMs, maxAgeMs) {
125
+ return walkJsonlFiles(roots)
126
+ .map((file) => {
127
+ try {
128
+ return { file, mtimeMs: fs.statSync(file).mtimeMs };
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ })
134
+ .filter((item) => item !== null && item.mtimeMs >= nowMs - maxAgeMs && item.mtimeMs <= nowMs + 5_000)
135
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)
136
+ .slice(0, MAX_RECENT_FILES_PER_PROVIDER)
137
+ .map((item) => item.file);
138
+ }
139
+ function tailContainsBinding(file, bindingToken) {
140
+ let descriptor = null;
141
+ try {
142
+ const stat = fs.statSync(file);
143
+ const bytes = Math.min(stat.size, MAX_TAIL_BYTES);
144
+ if (bytes <= 0)
145
+ return false;
146
+ descriptor = fs.openSync(file, "r");
147
+ const buffer = Buffer.allocUnsafe(bytes);
148
+ fs.readSync(descriptor, buffer, 0, bytes, stat.size - bytes);
149
+ const tail = buffer.toString("utf8");
150
+ return tail.includes(bindingToken) && tail.includes("bind_source_session");
151
+ }
152
+ catch {
153
+ return false;
154
+ }
155
+ finally {
156
+ if (descriptor !== null)
157
+ fs.closeSync(descriptor);
158
+ }
159
+ }
160
+ function codexSessionId(file) {
161
+ let descriptor = null;
162
+ try {
163
+ descriptor = fs.openSync(file, "r");
164
+ const stat = fs.fstatSync(descriptor);
165
+ const bytes = Math.min(stat.size, 128 * 1024);
166
+ const buffer = Buffer.allocUnsafe(bytes);
167
+ fs.readSync(descriptor, buffer, 0, bytes, 0);
168
+ for (const line of buffer.toString("utf8").split(/\r?\n/)) {
169
+ if (!line.includes("session_meta"))
170
+ continue;
171
+ try {
172
+ const row = JSON.parse(line);
173
+ if (!isRecord(row) || row.type !== "session_meta" || !isRecord(row.payload))
174
+ continue;
175
+ if (typeof row.payload.parent_thread_id === "string" && row.payload.parent_thread_id)
176
+ return null;
177
+ const raw = typeof row.payload.id === "string"
178
+ ? row.payload.id
179
+ : typeof row.payload.session_id === "string"
180
+ ? row.payload.session_id
181
+ : "";
182
+ return UUID_RE.test(raw) ? raw.toLowerCase() : null;
183
+ }
184
+ catch {
185
+ continue;
186
+ }
187
+ }
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ finally {
193
+ if (descriptor !== null)
194
+ fs.closeSync(descriptor);
195
+ }
196
+ return path.basename(file).match(UUID_IN_TEXT_RE)?.[0]?.toLowerCase() ?? null;
197
+ }
198
+ function claudeCodeSessionId(file) {
199
+ const value = path.basename(file, ".jsonl").trim();
200
+ return value && value.length <= 200 ? value : null;
201
+ }
202
+ function coworkSessionId(file) {
203
+ const value = file.split(path.sep).find((part) => /^local_[A-Za-z0-9_-]{1,194}$/.test(part));
204
+ return value ?? null;
205
+ }
206
+ function enabledProviders(hostPlatform) {
207
+ const normalized = hostPlatform?.toLowerCase().replace(/[^a-z0-9]+/g, "_") ?? "";
208
+ if (normalized.includes("codex"))
209
+ return ["codex"];
210
+ if (normalized.includes("claude_code"))
211
+ return ["claude-code"];
212
+ if (normalized.includes("claude_desktop") || normalized === "claude") {
213
+ return ["cowork", "claude-code"];
214
+ }
215
+ return ["codex", "claude-code", "cowork"];
216
+ }
217
+ export function resolveSourceSessionFromBindingToken(bindingToken, options = {}) {
218
+ const normalizedToken = bindingToken.trim().toLowerCase();
219
+ if (!UUID_RE.test(normalizedToken)) {
220
+ throw new Error("bindingToken must be a UUID generated fresh for this tool call");
221
+ }
222
+ const needsDefaults = options.roots?.codex === undefined
223
+ || options.roots?.claudeCode === undefined
224
+ || options.roots?.cowork === undefined;
225
+ const defaults = needsDefaults ? defaultRoots() : { codex: [], claudeCode: [], cowork: [] };
226
+ const roots = {
227
+ codex: options.roots?.codex ?? defaults.codex,
228
+ claudeCode: options.roots?.claudeCode ?? defaults.claudeCode,
229
+ cowork: options.roots?.cowork ?? defaults.cowork,
230
+ };
231
+ const nowMs = options.nowMs ?? Date.now();
232
+ const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
233
+ const matches = [];
234
+ for (const provider of enabledProviders(options.hostPlatform)) {
235
+ const providerRoots = provider === "codex"
236
+ ? roots.codex
237
+ : provider === "claude-code"
238
+ ? roots.claudeCode
239
+ : roots.cowork;
240
+ for (const file of recentFiles(providerRoots, nowMs, maxAgeMs)) {
241
+ if (!tailContainsBinding(file, normalizedToken))
242
+ continue;
243
+ const providerSessionId = provider === "codex"
244
+ ? codexSessionId(file)
245
+ : provider === "claude-code"
246
+ ? claudeCodeSessionId(file)
247
+ : coworkSessionId(file);
248
+ if (providerSessionId)
249
+ matches.push({ provider, providerSessionId });
250
+ }
251
+ }
252
+ const unique = new Map(matches.map((match) => [`${match.provider}:${match.providerSessionId}`, match]));
253
+ if (unique.size === 0) {
254
+ throw new Error("Could not verify this binding token in a recent local host JSONL. Retry bind_source_session with a new UUID after the host has recorded the tool call.");
255
+ }
256
+ if (unique.size !== 1) {
257
+ throw new Error("The binding token appeared in multiple source sessions; retry with a fresh UUID");
258
+ }
259
+ const verified = [...unique.values()][0];
260
+ return verifiedSourceSession(verified.provider, verified.providerSessionId, SOURCE_SESSION_BINDING_EVIDENCE);
261
+ }