From ee585a8370ae9bb331b17e0fc1dac387ab2d2b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:48:41 +0100 Subject: [PATCH] =?UTF-8?q?fix(cli):=20v0.5.7=20=E2=80=94=20event=20loop?= =?UTF-8?q?=20keepalive=20for=20stdout=20flush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node.js stdout to a pipe is buffered. Without periodic event loop activity, WS callback → server.notification() → stdout.write() may not flush until the next I/O event. A 1s setInterval (NOT unref'd) keeps the event loop ticking so notifications flush immediately. This is why claude-intercom worked: its 1s HTTP poll kept the event loop active as a side effect. Claudemesh's passive WS listener let the event loop settle, causing stdout to buffer indefinitely. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/cli/package.json | 2 +- apps/cli/src/mcp/server.ts | 27 ++++++++++++++++++++++----- apps/cli/src/ws/client.ts | 7 +++++-- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index f456ddc..659e833 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "0.5.6", + "version": "0.5.7", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "keywords": [ "claude-code", diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index ca73ef3..60c3a05 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -445,11 +445,14 @@ Your message mode is "${messageMode}". if (!existsSync(filePath)) return text(`share_file: file not found: ${filePath}`, true); const client = allClients()[0]; if (!client) return text("share_file: not connected", true); - const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, { - name: fileName, tags, persistent: true, - }); - if (!fileId) return text("share_file: upload failed", true); - return text(`Shared: ${fileName ?? filePath} (${fileId})`); + try { + const fileId = await client.uploadFile(filePath, client.meshId, client.meshSlug, { + name: fileName, tags, persistent: true, + }); + return text(`Shared: ${fileName ?? filePath} (${fileId})`); + } catch (e) { + return text(`share_file: upload failed — ${e instanceof Error ? e.message : String(e)}`, true); + } } case "get_file": { @@ -856,7 +859,21 @@ Your message mode is "${messageMode}". }); } + // Event loop keepalive: Node.js stdout to a pipe is buffered. Without + // periodic event loop activity, stdout.write() from WS callbacks may not + // flush until the next I/O event. This 1s interval keeps the event loop + // ticking so channel notifications flush promptly — same pattern that made + // claude-intercom's push delivery reliable (its 1s HTTP poll had this + // effect as a side effect). The interval does nothing except prevent the + // event loop from settling. + const keepalive = setInterval(() => { + // Intentionally empty — the interval itself keeps the event loop active. + // Do NOT call .unref() — that would defeat the purpose. + }, 1_000); + void keepalive; // suppress unused warning + const shutdown = (): void => { + clearInterval(keepalive); stopAll(); process.exit(0); }; diff --git a/apps/cli/src/ws/client.ts b/apps/cli/src/ws/client.ts index 3dd452f..bd60d3e 100644 --- a/apps/cli/src/ws/client.ts +++ b/apps/cli/src/ws/client.ts @@ -526,8 +526,11 @@ export class BrokerClient { body: data, signal: AbortSignal.timeout(30_000), }); - const body = await res.json() as { ok?: boolean; fileId?: string }; - return body.fileId ?? null; + const body = await res.json() as { ok?: boolean; fileId?: string; error?: string }; + if (!res.ok || !body.fileId) { + throw new Error(body.error ?? `HTTP ${res.status}`); + } + return body.fileId; } // --- Vectors ---