@echomem/mcp 1.4.16 → 1.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import readline from "node:readline";
7
+ const RPC_TIMEOUT_MS = 8_000;
8
+ const MAX_CONTEXT_CHARS = 512_000;
9
+ /**
10
+ * Open a fresh agent session and send the EchoMem carryover as its first user message.
11
+ * Codex starts a real app-server turn; Claude Code seeds a persisted session from stdin, then resumes
12
+ * it interactively. Neither path places the (potentially sensitive) carryover in process arguments.
13
+ */
14
+ export async function launchWarmSession(input, deps = {}) {
15
+ const prompt = input.prompt.trim();
16
+ if (!prompt || prompt.length > MAX_CONTEXT_CHARS)
17
+ return { ok: false, reason: "invalid_context" };
18
+ if (!path.isAbsolute(input.cwd) || !isDirectory(input.cwd))
19
+ return { ok: false, reason: "workspace_missing" };
20
+ if (input.client === "codex")
21
+ return launchCodexAppSession({ ...input, prompt }, deps);
22
+ if (input.client === "claude-code")
23
+ return launchClaudeCodeSession({ ...input, prompt }, deps);
24
+ return { ok: false, reason: "claude_desktop_requires_paste" };
25
+ }
26
+ export function codexFirstUserMessage(prompt) {
27
+ return { type: "text", text: prompt };
28
+ }
29
+ export function buildClaudeLaunchScript(args) {
30
+ const claude = args.claudeCommand || "claude";
31
+ return [
32
+ "#!/bin/zsh -l",
33
+ "set -u",
34
+ `PROMPT_FILE=${shellQuote(args.promptFile)}`,
35
+ `SCRIPT_FILE=${shellQuote(args.scriptFile)}`,
36
+ `SESSION_ID=${shellQuote(args.sessionId)}`,
37
+ 'cleanup() { rm -f -- "$PROMPT_FILE" "$SCRIPT_FILE"; }',
38
+ "trap cleanup EXIT HUP INT TERM",
39
+ `cd -- ${shellQuote(args.cwd)} || exit 1`,
40
+ 'echo "EchoMem is sending the carryover as this session\'s first message…"',
41
+ `if ! ${shellQuote(claude)} -p --session-id "$SESSION_ID" --name ${shellQuote(args.sessionName)} < "$PROMPT_FILE" >/dev/null; then`,
42
+ ' echo "EchoMem could not seed the Claude Code session."',
43
+ " exit 1",
44
+ "fi",
45
+ "cleanup",
46
+ "trap - EXIT HUP INT TERM",
47
+ `exec ${shellQuote(claude)} --resume "$SESSION_ID"`,
48
+ ].join("\n") + "\n";
49
+ }
50
+ /** Read the real workspace path from the transcript metadata without trusting a renderer payload. */
51
+ export function readSessionWorkingDirectory(filePath) {
52
+ let fd = null;
53
+ try {
54
+ fd = fs.openSync(filePath, "r");
55
+ const buffer = Buffer.alloc(512 * 1024);
56
+ const bytes = fs.readSync(fd, buffer, 0, buffer.length, 0);
57
+ let latest = null;
58
+ for (const line of buffer.subarray(0, bytes).toString("utf8").split("\n")) {
59
+ if (!line.trim())
60
+ continue;
61
+ let value;
62
+ try {
63
+ value = JSON.parse(line);
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ if (!isRecord(value))
69
+ continue;
70
+ const payload = isRecord(value.payload) ? value.payload : null;
71
+ const candidate = typeof value.cwd === "string" ? value.cwd : payload && typeof payload.cwd === "string" ? payload.cwd : "";
72
+ if (path.isAbsolute(candidate))
73
+ latest = candidate;
74
+ }
75
+ return latest;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ finally {
81
+ if (fd !== null)
82
+ fs.closeSync(fd);
83
+ }
84
+ }
85
+ async function launchCodexAppSession(input, deps) {
86
+ if (!deps.openExternal)
87
+ return { ok: false, reason: "no_desktop" };
88
+ const command = deps.codexCommand || resolveCodexCommand();
89
+ let child = null;
90
+ let rpc = null;
91
+ let threadId = "";
92
+ try {
93
+ child = spawn(command, ["app-server"], { stdio: ["pipe", "pipe", "pipe"], env: processEnv() });
94
+ rpc = new JsonRpcClient(child);
95
+ await rpc.request("initialize", {
96
+ clientInfo: { name: "echomem_hud", title: "EchoMem HUD", version: "1" },
97
+ capabilities: { experimentalApi: true },
98
+ });
99
+ rpc.notify("initialized", {});
100
+ const started = await rpc.request("thread/start", { cwd: input.cwd, threadSource: "echomem" });
101
+ threadId = nestedString(started, "thread", "id");
102
+ if (!threadId)
103
+ throw new Error("thread_start_failed");
104
+ await rpc.request("thread/name/set", { threadId, name: sessionName(input) });
105
+ const completion = rpc.waitForNotification("turn/completed", 30 * 60 * 1_000);
106
+ const turnStarted = await rpc.request("turn/start", {
107
+ threadId,
108
+ cwd: input.cwd,
109
+ input: [codexFirstUserMessage(input.prompt)],
110
+ }, 20_000);
111
+ const turnId = nestedString(turnStarted, "turn", "id");
112
+ if (!turnId)
113
+ throw new Error("turn_start_failed");
114
+ // Codex desktop does not live-refresh a thread that another app-server process opened before
115
+ // its first turn was persisted. Wait for the auto-sent carryover turn and a graceful app-server
116
+ // shutdown before deep-linking, so the task loads with the message already present.
117
+ await completion;
118
+ await rpc.closeAndWait();
119
+ rpc = null;
120
+ child = null;
121
+ const link = `codex://threads/${threadId}`;
122
+ try {
123
+ await deps.openExternal(link);
124
+ }
125
+ catch {
126
+ return { ok: false, mode: "codex-app", threadId, turnId, messageSent: true, link, reason: "open_failed" };
127
+ }
128
+ return { ok: true, mode: "codex-app", threadId, turnId, messageSent: true, link };
129
+ }
130
+ catch (error) {
131
+ if (rpc && threadId) {
132
+ try {
133
+ await rpc.request("thread/delete", { threadId }, 1_000);
134
+ }
135
+ catch {
136
+ /* best-effort cleanup of a partially created warm-start */
137
+ }
138
+ }
139
+ rpc?.close();
140
+ if (child && !child.killed)
141
+ child.kill();
142
+ return { ok: false, reason: launchErrorReason(error, "codex_launch_failed") };
143
+ }
144
+ }
145
+ async function launchClaudeCodeSession(input, deps) {
146
+ if (!deps.openPath)
147
+ return { ok: false, reason: "no_desktop" };
148
+ const dir = deps.launchDir || path.join(os.homedir(), ".echomem", "warm-starts");
149
+ const id = randomUUID();
150
+ const promptFile = path.join(dir, `${id}.md`);
151
+ const scriptFile = path.join(dir, `${id}.command`);
152
+ try {
153
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
154
+ pruneLaunchDir(dir);
155
+ fs.writeFileSync(promptFile, input.prompt, { encoding: "utf8", mode: 0o600 });
156
+ fs.writeFileSync(scriptFile, buildClaudeLaunchScript({
157
+ cwd: input.cwd,
158
+ promptFile,
159
+ scriptFile,
160
+ claudeCommand: deps.claudeCommand || process.env.ECHO_CLAUDE_BIN,
161
+ sessionId: id,
162
+ sessionName: sessionName(input),
163
+ }), { encoding: "utf8", mode: 0o700 });
164
+ fs.chmodSync(promptFile, 0o600);
165
+ fs.chmodSync(scriptFile, 0o700);
166
+ const openError = await deps.openPath(scriptFile);
167
+ if (openError)
168
+ throw new Error("open_failed");
169
+ return { ok: true, mode: "claude-code", threadId: id, messageSent: true };
170
+ }
171
+ catch (error) {
172
+ safeRemove(promptFile);
173
+ safeRemove(scriptFile);
174
+ return { ok: false, reason: launchErrorReason(error, "claude_launch_failed") };
175
+ }
176
+ }
177
+ class JsonRpcClient {
178
+ child;
179
+ nextId = 1;
180
+ pending = new Map();
181
+ lines;
182
+ notificationWaiters = new Set();
183
+ closed = false;
184
+ constructor(child) {
185
+ this.child = child;
186
+ this.lines = readline.createInterface({ input: child.stdout });
187
+ this.lines.on("line", (line) => this.onLine(line));
188
+ // Drain diagnostics without logging them: this prevents a full stderr pipe from stalling the
189
+ // app-server and keeps local context/error details out of HUD stdout.
190
+ child.stderr.resume();
191
+ child.stdin.on("error", () => this.failAll(new Error("codex_app_server_write_failed")));
192
+ child.once("error", () => this.failAll(new Error("codex_not_found")));
193
+ child.once("exit", () => this.failAll(new Error("codex_app_server_exited")));
194
+ }
195
+ request(method, params, timeoutMs = RPC_TIMEOUT_MS) {
196
+ if (this.closed)
197
+ return Promise.reject(new Error("codex_app_server_closed"));
198
+ const id = this.nextId++;
199
+ return new Promise((resolve, reject) => {
200
+ const timer = setTimeout(() => {
201
+ this.pending.delete(id);
202
+ reject(new Error("codex_app_server_timeout"));
203
+ }, timeoutMs);
204
+ this.pending.set(id, { resolve, reject, timer });
205
+ this.write({ method, id, params });
206
+ });
207
+ }
208
+ notify(method, params) {
209
+ this.write({ method, params });
210
+ }
211
+ waitForNotification(method, timeoutMs) {
212
+ if (this.closed)
213
+ return Promise.reject(new Error("codex_app_server_closed"));
214
+ return new Promise((resolve, reject) => {
215
+ const waiter = {
216
+ method,
217
+ resolve,
218
+ reject,
219
+ timer: setTimeout(() => {
220
+ this.notificationWaiters.delete(waiter);
221
+ reject(new Error("codex_app_server_timeout"));
222
+ }, timeoutMs),
223
+ };
224
+ waiter.timer.unref();
225
+ this.notificationWaiters.add(waiter);
226
+ });
227
+ }
228
+ close() {
229
+ if (this.closed)
230
+ return;
231
+ this.beginClose();
232
+ const child = this.child;
233
+ const timer = setTimeout(() => {
234
+ if (child.exitCode === null && child.signalCode === null)
235
+ child.kill();
236
+ }, 500);
237
+ timer.unref();
238
+ }
239
+ async closeAndWait(timeoutMs = 1_500) {
240
+ this.beginClose();
241
+ if (await waitForChildExit(this.child, timeoutMs))
242
+ return;
243
+ this.child.kill();
244
+ await waitForChildExit(this.child, 500);
245
+ }
246
+ beginClose() {
247
+ if (this.closed)
248
+ return;
249
+ this.closed = true;
250
+ this.failAll(new Error("codex_app_server_closed"));
251
+ this.lines.close();
252
+ this.child.stdin.end();
253
+ }
254
+ write(message) {
255
+ try {
256
+ this.child.stdin.write(`${JSON.stringify(message)}\n`);
257
+ }
258
+ catch {
259
+ this.failAll(new Error("codex_app_server_write_failed"));
260
+ }
261
+ }
262
+ onLine(line) {
263
+ let message;
264
+ try {
265
+ message = JSON.parse(line);
266
+ }
267
+ catch {
268
+ return;
269
+ }
270
+ if (!isRecord(message))
271
+ return;
272
+ if (typeof message.method === "string" && typeof message.id !== "number") {
273
+ for (const waiter of this.notificationWaiters) {
274
+ if (waiter.method !== message.method)
275
+ continue;
276
+ this.notificationWaiters.delete(waiter);
277
+ clearTimeout(waiter.timer);
278
+ waiter.resolve(message);
279
+ }
280
+ return;
281
+ }
282
+ if (typeof message.id !== "number")
283
+ return;
284
+ const pending = this.pending.get(message.id);
285
+ if (!pending)
286
+ return;
287
+ this.pending.delete(message.id);
288
+ clearTimeout(pending.timer);
289
+ if (isRecord(message.error)) {
290
+ pending.reject(new Error(typeof message.error.message === "string" ? message.error.message : "codex_rpc_error"));
291
+ }
292
+ else {
293
+ pending.resolve(message.result);
294
+ }
295
+ }
296
+ failAll(error) {
297
+ for (const pending of this.pending.values()) {
298
+ clearTimeout(pending.timer);
299
+ pending.reject(error);
300
+ }
301
+ this.pending.clear();
302
+ for (const waiter of this.notificationWaiters) {
303
+ clearTimeout(waiter.timer);
304
+ waiter.reject(error);
305
+ }
306
+ this.notificationWaiters.clear();
307
+ }
308
+ }
309
+ function resolveCodexCommand() {
310
+ if (process.env.ECHO_CODEX_BIN)
311
+ return process.env.ECHO_CODEX_BIN;
312
+ const bundled = "/Applications/ChatGPT.app/Contents/Resources/codex";
313
+ return fs.existsSync(bundled) ? bundled : "codex";
314
+ }
315
+ function sessionName(input) {
316
+ const project = path.basename(input.cwd) || "workspace";
317
+ return (`EchoMem carryover · ${project}`).slice(0, 80);
318
+ }
319
+ function waitForChildExit(child, timeoutMs) {
320
+ if (child.exitCode !== null || child.signalCode !== null)
321
+ return Promise.resolve(true);
322
+ return new Promise((resolve) => {
323
+ let settled = false;
324
+ const finish = (exited) => {
325
+ if (settled)
326
+ return;
327
+ settled = true;
328
+ clearTimeout(timer);
329
+ child.off("exit", onExit);
330
+ resolve(exited);
331
+ };
332
+ const onExit = () => finish(true);
333
+ const timer = setTimeout(() => finish(false), timeoutMs);
334
+ timer.unref();
335
+ child.once("exit", onExit);
336
+ });
337
+ }
338
+ function processEnv() {
339
+ return { ...process.env, NO_COLOR: "1" };
340
+ }
341
+ function nestedString(value, outer, inner) {
342
+ if (!isRecord(value) || !isRecord(value[outer]))
343
+ return "";
344
+ return typeof value[outer][inner] === "string" ? value[outer][inner] : "";
345
+ }
346
+ function shellQuote(value) {
347
+ return `'${value.replace(/'/g, `'\\''`)}'`;
348
+ }
349
+ function isDirectory(value) {
350
+ try {
351
+ return fs.statSync(value).isDirectory();
352
+ }
353
+ catch {
354
+ return false;
355
+ }
356
+ }
357
+ function pruneLaunchDir(dir) {
358
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
359
+ try {
360
+ for (const name of fs.readdirSync(dir)) {
361
+ if (!/^[0-9a-f-]+\.(?:md|command)$/i.test(name))
362
+ continue;
363
+ const file = path.join(dir, name);
364
+ if (fs.statSync(file).mtimeMs < cutoff)
365
+ safeRemove(file);
366
+ }
367
+ }
368
+ catch {
369
+ /* cleanup is best-effort */
370
+ }
371
+ }
372
+ function safeRemove(file) {
373
+ try {
374
+ fs.rmSync(file, { force: true });
375
+ }
376
+ catch {
377
+ /* best-effort */
378
+ }
379
+ }
380
+ function launchErrorReason(error, fallback) {
381
+ if (!(error instanceof Error))
382
+ return fallback;
383
+ const known = new Set([
384
+ "codex_not_found",
385
+ "codex_app_server_exited",
386
+ "codex_app_server_timeout",
387
+ "thread_start_failed",
388
+ "turn_start_failed",
389
+ "open_failed",
390
+ ]);
391
+ return known.has(error.message) ? error.message : fallback;
392
+ }
393
+ function isRecord(value) {
394
+ return typeof value === "object" && value !== null && !Array.isArray(value);
395
+ }