@echomem/mcp 1.4.39 → 1.4.41

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.",
@@ -1285,10 +1285,12 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1285
1285
  var slot = document.getElementById("setupPlan");
1286
1286
  if (!slot) return;
1287
1287
  var readySettings = slot.closest ? slot.closest(".readySettings") : null;
1288
+ var planGateDecision = slot.closest ? slot.closest(".planGateDecision") : null;
1288
1289
  if (!canExtract) {
1289
1290
  slot.innerHTML = "";
1290
1291
  slot.classList.add("is-hidden");
1291
1292
  if (readySettings) readySettings.classList.remove("is-plan-open", "is-checkout-pending");
1293
+ if (planGateDecision) planGateDecision.classList.remove("is-checkout-pending");
1292
1294
  renderBillingCommitment("");
1293
1295
  return;
1294
1296
  }
@@ -1303,6 +1305,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1303
1305
  return;
1304
1306
  }
1305
1307
  if (readySettings) readySettings.classList.remove("is-plan-open", "is-checkout-pending");
1308
+ if (planGateDecision) planGateDecision.classList.remove("is-checkout-pending");
1306
1309
  rememberSetupPlanChoice("");
1307
1310
  setupPlanChoice = "paid";
1308
1311
  var planLabel = plan.charAt(0).toUpperCase() + plan.slice(1);
@@ -1329,6 +1332,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1329
1332
  }
1330
1333
  if (setupPlanChoice === "free") {
1331
1334
  if (readySettings) readySettings.classList.remove("is-plan-open", "is-checkout-pending");
1335
+ if (planGateDecision) planGateDecision.classList.remove("is-checkout-pending");
1332
1336
  slot.innerHTML = '<div class="setupPlanConfirmed"><span class="setupPlanConfirmedIcon" aria-hidden="true">' + setupIcon("check") + '</span><span class="setupPlanConfirmedCopy"><strong>Original Echo</strong><span>100 coding sessions and 100 memory recalls each week.</span></span><button type="button" class="textButton" id="changeSetupPlan">Change</button></div>';
1333
1337
  renderBillingCommitment("");
1334
1338
  var changeBtn = document.getElementById("changeSetupPlan");
@@ -1352,6 +1356,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1352
1356
  readySettings.classList.remove("is-plan-open");
1353
1357
  readySettings.classList.add("is-checkout-pending");
1354
1358
  }
1359
+ if (planGateDecision) planGateDecision.classList.add("is-checkout-pending");
1355
1360
  var selectedPaidPlan = pendingPaidPlan === "power" ? "Power" : "Pro";
1356
1361
  var pendingHasTrial = !billingStatus || billingStatus.trialAvailable !== false;
1357
1362
  var pendingPrice = pendingPaidPlan === "power" ? "$100" : "$20";
@@ -1361,15 +1366,15 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1361
1366
  : "Checking automatically every few seconds");
1362
1367
  slot.innerHTML =
1363
1368
  '<section class="setupPlanChoice setupPlanCheckout" aria-labelledby="setupPlanHeading">' +
1364
- '<div class="setupPlanIntro"><h3 id="setupPlanHeading">' + esc(pendingHasTrial ? selectedPaidPlan + " trial — $0 today" : selectedPaidPlan + " subscription") + '</h3><p>Finish in Stripe. This page updates automatically.</p></div>' +
1369
+ '<div class="setupPlanIntro"><h3 id="setupPlanHeading">' + esc(pendingHasTrial ? selectedPaidPlan + " trial — $0 today" : selectedPaidPlan + " subscription") + '</h3><p>Complete checkout in Stripe, then return here. This page updates automatically.</p></div>' +
1365
1370
  '<dl class="checkoutCommitment">' +
1366
1371
  '<div><dt>Today</dt><dd>' + esc(pendingHasTrial ? "$0" : pendingPrice) + '</dd></div>' +
1367
1372
  '<div><dt>History import</dt><dd>' + esc(pendingLimit) + ' sessions</dd></div>' +
1368
1373
  '<div><dt>' + esc(pendingHasTrial ? "First charge" : "Billing") + '</dt><dd>' + esc(pendingHasTrial ? pendingPrice + " on " + trialChargeDateText() : pendingPrice + "/month now") + '</dd></div>' +
1369
1374
  '</dl>' +
1370
- '<div class="checkoutActions"><button type="button" class="primary" id="continueSelectedPaidPlan">Reopen Stripe checkout</button><button type="button" class="secondary" id="checkPaymentStatus">Check payment status</button></div>' +
1375
+ '<div class="checkoutActions"><button type="button" class="primary" id="continueSelectedPaidPlan">Reopen Stripe checkout</button></div>' +
1371
1376
  '<p class="checkoutPollStatus" id="setupPlanStatus" role="status" aria-live="polite">' + esc(pendingStatus) + '</p>' +
1372
- '<div class="setupPlanFoot"><button type="button" class="textButton" id="changeSelectedPaidPlan">Choose another plan</button></div>' +
1377
+ '<div class="setupPlanFoot"><button type="button" class="textButton" id="checkPaymentStatus">Check payment status</button><button type="button" class="textButton" id="changeSelectedPaidPlan">Choose another plan</button></div>' +
1373
1378
  '</section>';
1374
1379
  var checkPaid = document.getElementById("checkPaymentStatus");
1375
1380
  if (checkPaid) checkPaid.onclick = function () {
@@ -1395,6 +1400,7 @@ export const SETUP_PAGE_CLIENT_EXTRACTION = String.raw ` /* ---------- dash
1395
1400
  readySettings.classList.remove("is-checkout-pending");
1396
1401
  readySettings.classList.add("is-plan-open");
1397
1402
  }
1403
+ if (planGateDecision) planGateDecision.classList.remove("is-checkout-pending");
1398
1404
  slot.innerHTML = renderPlanHabitat();
1399
1405
  renderBillingCommitment("");
1400
1406
  bindPlanHabitat();
@@ -667,6 +667,52 @@ export const SETUP_PAGE_STYLES_MVP = String.raw `
667
667
  color: var(--echo-ink-text);
668
668
  text-align: left;
669
669
  }
670
+ .planGateDecision.is-checkout-pending {
671
+ width: min(680px, 100%);
672
+ }
673
+ .planGateDecision.is-checkout-pending .setupPlanSlot {
674
+ margin-top: 8px;
675
+ }
676
+ .planGateDecision.is-checkout-pending .setupPlanCheckout {
677
+ display: grid;
678
+ gap: 16px;
679
+ margin: 0 auto;
680
+ overflow: visible;
681
+ border: 1px solid rgba(26,58,143,0.11);
682
+ border-radius: 20px;
683
+ background: rgba(255,255,255,0.78);
684
+ padding: clamp(22px, 4vw, 34px);
685
+ box-shadow: 0 22px 55px -42px rgba(20,36,84,0.5);
686
+ backdrop-filter: blur(10px);
687
+ }
688
+ .planGateDecision.is-checkout-pending .setupPlanIntro {
689
+ padding: 0;
690
+ }
691
+ .planGateDecision.is-checkout-pending .setupPlanIntro h3 {
692
+ font-size: clamp(25px, 3vw, 32px);
693
+ line-height: 1.2;
694
+ }
695
+ .planGateDecision.is-checkout-pending .setupPlanIntro p {
696
+ max-width: 620px;
697
+ margin-top: 7px;
698
+ font-size: 14px;
699
+ line-height: 1.5;
700
+ }
701
+ .planGateDecision.is-checkout-pending .checkoutActions {
702
+ grid-template-columns: 1fr;
703
+ }
704
+ .planGateDecision.is-checkout-pending .setupPlanFoot {
705
+ justify-content: flex-start;
706
+ gap: 18px;
707
+ margin-top: 0;
708
+ }
709
+ .planGateDecision.is-checkout-pending .setupPlanFoot .textButton {
710
+ min-height: 0;
711
+ padding: 2px 0;
712
+ color: var(--echo-ink-mute);
713
+ font-size: 11px;
714
+ font-weight: 700;
715
+ }
670
716
  .mvpPricing {
671
717
  width: 100%;
672
718
  }
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.",
@@ -416,7 +417,26 @@ export function writeJsonClientConfig(configPath, entry) {
416
417
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
417
418
  }
418
419
  export function writeClaudeCodeConfig(entry) {
419
- const addArguments = ["mcp", "add-json", "-s", "local", "echomem", JSON.stringify(entry)];
420
+ // EchoMem is a memory server that should load in EVERY Claude Code project, so it belongs at
421
+ // `user` scope (~/.claude.json, all projects) rather than `local` scope (the current project only).
422
+ const addArguments = ["mcp", "add-json", "-s", "user", "echomem", JSON.stringify(entry)];
423
+ const removeFromScope = (scope) => {
424
+ try {
425
+ execFileSync("claude", ["mcp", "remove", "echomem", "-s", scope], {
426
+ encoding: "utf8",
427
+ stdio: ["ignore", "pipe", "pipe"],
428
+ timeout: 10000,
429
+ });
430
+ return true;
431
+ }
432
+ catch {
433
+ return false;
434
+ }
435
+ };
436
+ // Older builds installed EchoMem at `local` scope. Left in place it would shadow the user-scoped
437
+ // entry and keep launching the stale command, so drop it first. Best-effort: `local` is per-project,
438
+ // so this only clears the directory setup runs from — a no-op (ignored) when nothing is there.
439
+ removeFromScope("local");
420
440
  try {
421
441
  execFileSync("claude", addArguments, {
422
442
  encoding: "utf8",
@@ -431,14 +451,11 @@ export function writeClaudeCodeConfig(entry) {
431
451
  if (!detail.includes("already exists"))
432
452
  return "unavailable";
433
453
  }
434
- // Claude Code's CLI will not replace a same-name local server. Once the replacement entry is
435
- // fully constructed, remove only EchoMem and immediately re-add it; sibling MCP servers remain.
454
+ // Claude Code's CLI will not replace a same-name server. Once the replacement entry is fully
455
+ // constructed, remove only EchoMem and immediately re-add it; sibling MCP servers remain.
436
456
  try {
437
- execFileSync("claude", ["mcp", "remove", "echomem", "-s", "local"], {
438
- encoding: "utf8",
439
- stdio: ["ignore", "pipe", "pipe"],
440
- timeout: 10000,
441
- });
457
+ if (!removeFromScope("user"))
458
+ return "unavailable";
442
459
  execFileSync("claude", addArguments, {
443
460
  encoding: "utf8",
444
461
  stdio: ["ignore", "pipe", "pipe"],
@@ -2686,8 +2703,8 @@ async function cmdSetup(flags) {
2686
2703
  if (!flags["no-codex-skills"]) {
2687
2704
  writeCodexSkillsForTargets(targets);
2688
2705
  }
2689
- if (flags["install-save-hooks"]) {
2690
- writeSaveCheckpointHooksForTargets(targets);
2706
+ if (flags["no-save-hooks"] !== true) {
2707
+ writeLifecycleHooksForTargets(targets);
2691
2708
  }
2692
2709
  console.log("");
2693
2710
  if (flags["skip-login"] || flags["no-login"]) {
@@ -2777,7 +2794,7 @@ function writeCodexSkillsForTargets(targets) {
2777
2794
  console.log(`ℹ️ Could not install EchoMem Codex skills: ${error instanceof Error ? error.message : String(error)}`);
2778
2795
  }
2779
2796
  }
2780
- function writeSaveCheckpointHooksForTargets(targets) {
2797
+ function writeLifecycleHooksForTargets(targets) {
2781
2798
  const clients = new Set();
2782
2799
  if (targets.some((target) => target.id === "codex"))
2783
2800
  clients.add("codex");
@@ -2788,8 +2805,10 @@ function writeSaveCheckpointHooksForTargets(targets) {
2788
2805
  return;
2789
2806
  }
2790
2807
  const mode = clients.size === 2 ? "both" : [...clients][0];
2791
- const paths = installSaveCheckpointHooks(mode);
2792
- console.log(`✅ Installed EchoMem private-save checkpoint hooks:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
2808
+ const sourcePaths = installSourceSessionHooks(mode);
2809
+ const savePaths = installSaveCheckpointHooks(mode);
2810
+ const paths = [...new Set([...sourcePaths, ...savePaths])];
2811
+ console.log(`✅ Installed EchoMem source-session and private-save hooks:\n${paths.map((p) => ` - ${p}`).join("\n")}`);
2793
2812
  if (clients.has("codex")) {
2794
2813
  console.log(" Codex: start a new session and run /hooks once to review and trust the hook.");
2795
2814
  }
@@ -3794,7 +3813,7 @@ Manual / headless:
3794
3813
  ${MCP_UPDATE_ALL_COMMAND} # one-shot latest update for detected clients, no browser login
3795
3814
  ${MCP_UPDATE_COMMAND} --client codex # update one client only
3796
3815
  echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
3797
- echomem-mcp setup --install-save-hooks --all Install proactive private-save completion checks
3816
+ echomem-mcp setup --install-save-hooks --all Install source-session + private-save lifecycle hooks
3798
3817
  echomem-mcp sync-usage --days 7 --limit 50 --dry-run
3799
3818
 
3800
3819
  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
+ }