@wipcomputer/wip-ldm-os 0.4.78 → 0.4.80

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.
package/SKILL.md CHANGED
@@ -9,7 +9,7 @@ license: MIT
9
9
  compatibility: Requires git, npm, node. Node.js 18+.
10
10
  metadata:
11
11
  display-name: "LDM OS"
12
- version: "0.4.78"
12
+ version: "0.4.80"
13
13
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
14
14
  author: "Parker Todd Brooks"
15
15
  category: infrastructure
package/bin/ldm.js CHANGED
@@ -2892,6 +2892,63 @@ async function cmdDoctor() {
2892
2892
  cleanupStaleClaudeCodeEnv();
2893
2893
  }
2894
2894
 
2895
+ // 3b. MCP health check (Phase 3c)
2896
+ //
2897
+ // Walk ~/.claude.json#mcpServers. For every entry whose command is node
2898
+ // and whose first arg resolves under ~/.ldm/extensions/ or ~/.openclaw/
2899
+ // extensions/, verify the file exists and parses. Report any that do not.
2900
+ //
2901
+ // This catches the failure mode where an extension renamed its MCP file,
2902
+ // updated its deployed artifacts, but the old .claude.json entry still
2903
+ // points at a path that was rotated into _trash. Surfacing these in
2904
+ // doctor makes the class of failure loud instead of silent.
2905
+ {
2906
+ const ccUserPath = join(HOME, '.claude.json');
2907
+ const ccUser = readJSON(ccUserPath);
2908
+ const ldmExtRoot = LDM_EXTENSIONS;
2909
+ const ocExtRoot = join(HOME, '.openclaw', 'extensions');
2910
+ const broken = [];
2911
+ if (ccUser?.mcpServers) {
2912
+ for (const [name, cfg] of Object.entries(ccUser.mcpServers)) {
2913
+ if (cfg?.command !== 'node') continue;
2914
+ const args = Array.isArray(cfg.args) ? cfg.args : [];
2915
+ const first = args[0];
2916
+ if (!first || typeof first !== 'string') continue;
2917
+ const underExt = first.startsWith(ldmExtRoot + '/') || first.startsWith(ocExtRoot + '/');
2918
+ if (!underExt) continue;
2919
+ if (!existsSync(first)) {
2920
+ broken.push({ name, path: first, reason: 'missing' });
2921
+ continue;
2922
+ }
2923
+ try {
2924
+ execSync(`node --check "${first}"`, { stdio: 'pipe', timeout: 5000 });
2925
+ } catch (e) {
2926
+ const stderr = (e && e.stderr && e.stderr.toString && e.stderr.toString().trim()) || (e && e.message) || 'unparseable';
2927
+ broken.push({ name, path: first, reason: 'unparseable', detail: stderr.split('\n')[0] });
2928
+ }
2929
+ }
2930
+ }
2931
+ if (broken.length > 0) {
2932
+ for (const b of broken) {
2933
+ const detail = b.detail ? ` (${b.detail})` : '';
2934
+ console.log(` ! MCP ${b.name}: ${b.reason} at ${b.path}${detail}`);
2935
+ }
2936
+ if (FIX_FLAG && ccUser?.mcpServers) {
2937
+ for (const b of broken) {
2938
+ delete ccUser.mcpServers[b.name];
2939
+ console.log(` + Removed dangling MCP: ${b.name}`);
2940
+ }
2941
+ writeFileSync(ccUserPath, JSON.stringify(ccUser, null, 2) + '\n');
2942
+ // --fix resolved them, do not count as outstanding issues
2943
+ } else {
2944
+ console.log(` Run: ldm doctor --fix to remove ${broken.length} dangling MCP entr${broken.length === 1 ? 'y' : 'ies'}`);
2945
+ issues += broken.length;
2946
+ }
2947
+ } else if (ccUser?.mcpServers && Object.keys(ccUser.mcpServers).length > 0) {
2948
+ console.log(` + MCP entries under LDM/OC extensions: all paths exist and parse`);
2949
+ }
2950
+ }
2951
+
2895
2952
  // 4. Check sacred locations
2896
2953
  const sacred = [
2897
2954
  { path: join(LDM_ROOT, 'memory'), label: 'memory/' },
@@ -156,18 +156,41 @@ function messageMatchesSession(msgTo, agentId, sessionName) {
156
156
  if (target.session === "*") return true;
157
157
  return target.session === sessionName;
158
158
  }
159
+ function findMessageById(id) {
160
+ if (!id) return null;
161
+ for (const dir of [MESSAGES_DIR, PROCESSED_DIR]) {
162
+ const candidate = join(dir, `${id}.json`);
163
+ if (!existsSync(candidate)) continue;
164
+ try {
165
+ const data = JSON.parse(readFileSync(candidate, "utf-8"));
166
+ return data;
167
+ } catch {
168
+ }
169
+ }
170
+ return null;
171
+ }
159
172
  function pushInbox(msg) {
160
173
  try {
161
174
  mkdirSync(MESSAGES_DIR, { recursive: true });
162
175
  const id = randomUUID();
176
+ let resolvedTo = msg.to;
177
+ const explicitBroadcast = resolvedTo === "*" || resolvedTo === "all" || resolvedTo && resolvedTo.endsWith(":*");
178
+ const agentOnly = resolvedTo && !resolvedTo.includes(":") && !explicitBroadcast;
179
+ if (msg.inReplyTo && (!resolvedTo || agentOnly)) {
180
+ const original = findMessageById(msg.inReplyTo);
181
+ if (original && original.from) {
182
+ resolvedTo = original.from;
183
+ }
184
+ }
163
185
  const data = {
164
186
  id,
165
187
  type: msg.type || "chat",
166
188
  from: msg.from || "unknown",
167
- to: msg.to || `${_sessionAgentId}:${_sessionName}`,
189
+ to: resolvedTo || `${_sessionAgentId}:${_sessionName}`,
168
190
  body: msg.body || msg.message || "",
169
191
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
170
- read: false
192
+ read: false,
193
+ ...msg.inReplyTo ? { inReplyTo: msg.inReplyTo } : {}
171
194
  };
172
195
  writeFileSync(join(MESSAGES_DIR, `${id}.json`), JSON.stringify(data, null, 2) + "\n");
173
196
  return inboxCount();
@@ -245,14 +268,27 @@ function sendLdmMessage(opts) {
245
268
  try {
246
269
  mkdirSync(MESSAGES_DIR, { recursive: true });
247
270
  const id = randomUUID();
271
+ let resolvedTo = opts.to;
272
+ const explicitBroadcast = resolvedTo === "*" || resolvedTo === "all" || resolvedTo && resolvedTo.endsWith(":*");
273
+ const agentOnly = resolvedTo && !resolvedTo.includes(":") && !explicitBroadcast;
274
+ if (opts.inReplyTo && (!resolvedTo || agentOnly)) {
275
+ const original = findMessageById(opts.inReplyTo);
276
+ if (original && original.from) {
277
+ resolvedTo = original.from;
278
+ }
279
+ }
280
+ if (!resolvedTo) {
281
+ return null;
282
+ }
248
283
  const data = {
249
284
  id,
250
285
  type: opts.type || "chat",
251
286
  from: opts.from || `${_sessionAgentId}:${_sessionName}`,
252
- to: opts.to,
287
+ to: resolvedTo,
253
288
  body: opts.body,
254
289
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
255
- read: false
290
+ read: false,
291
+ ...opts.inReplyTo ? { inReplyTo: opts.inReplyTo } : {}
256
292
  };
257
293
  writeFileSync(join(MESSAGES_DIR, `${id}.json`), JSON.stringify(data, null, 2) + "\n");
258
294
  return id;
@@ -652,6 +688,7 @@ export {
652
688
  setSessionIdentity,
653
689
  getSessionIdentity,
654
690
  refreshSessionIdentity,
691
+ findMessageById,
655
692
  pushInbox,
656
693
  drainInbox,
657
694
  inboxCount,
@@ -8,7 +8,7 @@ import {
8
8
  searchConversations,
9
9
  searchWorkspace,
10
10
  sendMessage
11
- } from "./chunk-7NH6JBIO.js";
11
+ } from "./chunk-O65O6CCM.js";
12
12
  import "./chunk-3RG5ZIWI.js";
13
13
 
14
14
  // cli.ts
@@ -20,6 +20,7 @@ interface InboxMessage {
20
20
  message?: string;
21
21
  timestamp: string;
22
22
  read: boolean;
23
+ inReplyTo?: string;
23
24
  }
24
25
  interface ConversationResult {
25
26
  text: string;
@@ -60,9 +61,23 @@ declare function getSessionIdentity(): {
60
61
  * Cheap: one file read per call. No network. No delay.
61
62
  */
62
63
  declare function refreshSessionIdentity(): void;
64
+ /**
65
+ * Look up a message by id in the inbox or processed dir. Returns null if
66
+ * not found. Used by reply-to-sender routing.
67
+ */
68
+ declare function findMessageById(id: string): InboxMessage | null;
63
69
  /**
64
70
  * Write a message to the file-based inbox.
65
71
  * Creates a JSON file at ~/.ldm/messages/{uuid}.json.
72
+ *
73
+ * Reply-to-sender routing (added 2026-04-20):
74
+ * If `inReplyTo` is set AND `to` is missing or agent-only (no colon),
75
+ * the bridge looks up the referenced message and copies its `from` into
76
+ * this message's `to`. This makes replies land at the specific session
77
+ * that sent the original, rather than broadcasting to every session of
78
+ * the agent (which is what Apr 10's Option 1 shipped as a safety net).
79
+ * Callers that explicitly want broadcast can still use
80
+ * `to: "<agent>:*"` or `to: "*"`.
66
81
  */
67
82
  declare function pushInbox(msg: {
68
83
  from: string;
@@ -70,6 +85,7 @@ declare function pushInbox(msg: {
70
85
  body?: string;
71
86
  to?: string;
72
87
  type?: string;
88
+ inReplyTo?: string;
73
89
  }): number;
74
90
  /**
75
91
  * Read and drain all messages for this session from the inbox.
@@ -92,9 +108,10 @@ declare function inboxCountBySession(): Record<string, number>;
92
108
  */
93
109
  declare function sendLdmMessage(opts: {
94
110
  from?: string;
95
- to: string;
111
+ to?: string;
96
112
  body: string;
97
113
  type?: string;
114
+ inReplyTo?: string;
98
115
  }): string | null;
99
116
  interface SessionInfo {
100
117
  name: string;
@@ -144,4 +161,4 @@ declare function discoverSkills(openclawDir: string): SkillInfo[];
144
161
  declare function executeSkillScript(skillDir: string, scripts: string[], scriptName: string | undefined, args: string): Promise<string>;
145
162
  declare function readWorkspaceFile(workspaceDir: string, filePath: string): WorkspaceFileResult;
146
163
 
147
- export { type BridgeConfig, type ConversationResult, type GatewayConfig, type InboxMessage, LDM_ROOT, type SessionInfo, type SkillInfo, type WorkspaceFileResult, type WorkspaceSearchResult, blobToEmbedding, cosineSimilarity, discoverSkills, drainInbox, executeSkillScript, findMarkdownFiles, getQueryEmbedding, getSessionIdentity, inboxCount, inboxCountBySession, listActiveSessions, pushInbox, readWorkspaceFile, refreshSessionIdentity, registerBridgeSession, resolveApiKey, resolveConfig, resolveConfigMulti, resolveGatewayConfig, searchConversations, searchWorkspace, sendLdmMessage, sendMessage, setSessionIdentity };
164
+ export { type BridgeConfig, type ConversationResult, type GatewayConfig, type InboxMessage, LDM_ROOT, type SessionInfo, type SkillInfo, type WorkspaceFileResult, type WorkspaceSearchResult, blobToEmbedding, cosineSimilarity, discoverSkills, drainInbox, executeSkillScript, findMarkdownFiles, findMessageById, getQueryEmbedding, getSessionIdentity, inboxCount, inboxCountBySession, listActiveSessions, pushInbox, readWorkspaceFile, refreshSessionIdentity, registerBridgeSession, resolveApiKey, resolveConfig, resolveConfigMulti, resolveGatewayConfig, searchConversations, searchWorkspace, sendLdmMessage, sendMessage, setSessionIdentity };
@@ -6,6 +6,7 @@ import {
6
6
  drainInbox,
7
7
  executeSkillScript,
8
8
  findMarkdownFiles,
9
+ findMessageById,
9
10
  getQueryEmbedding,
10
11
  getSessionIdentity,
11
12
  inboxCount,
@@ -24,7 +25,7 @@ import {
24
25
  sendLdmMessage,
25
26
  sendMessage,
26
27
  setSessionIdentity
27
- } from "./chunk-7NH6JBIO.js";
28
+ } from "./chunk-O65O6CCM.js";
28
29
  import "./chunk-3RG5ZIWI.js";
29
30
  export {
30
31
  LDM_ROOT,
@@ -34,6 +35,7 @@ export {
34
35
  drainInbox,
35
36
  executeSkillScript,
36
37
  findMarkdownFiles,
38
+ findMessageById,
37
39
  getQueryEmbedding,
38
40
  getSessionIdentity,
39
41
  inboxCount,
@@ -15,7 +15,7 @@ import {
15
15
  sendLdmMessage,
16
16
  sendMessage,
17
17
  setSessionIdentity
18
- } from "./chunk-7NH6JBIO.js";
18
+ } from "./chunk-O65O6CCM.js";
19
19
  import {
20
20
  __require
21
21
  } from "./chunk-3RG5ZIWI.js";
@@ -225,7 +225,7 @@ Message delivered to the gateway (fire-and-forget). L\u0113sa will process it th
225
225
  server.registerTool(
226
226
  "lesa_check_inbox",
227
227
  {
228
- description: "Check for pending messages in the file-based inbox (~/.ldm/messages/). Messages can come from OpenClaw agents, other Claude Code sessions, or CLI. Returns all pending messages for this session and marks them as read.",
228
+ description: "Check for pending messages in the file-based inbox (~/.ldm/messages/). Messages can come from OpenClaw agents, other Claude Code sessions, or CLI. Returns all pending messages for this session and marks them as read. Each entry includes [id: <uuid>] so you can pass it to lesa_reply_to_sender when replying to a specific message.",
229
229
  inputSchema: {}
230
230
  },
231
231
  async () => {
@@ -233,13 +233,43 @@ server.registerTool(
233
233
  if (messages.length === 0) {
234
234
  return { content: [{ type: "text", text: "No pending messages." }] };
235
235
  }
236
- const text = messages.map((m) => `**${m.from}** [${m.type}] (${m.timestamp}):
236
+ const text = messages.map((m) => `**${m.from}** [${m.type}] (${m.timestamp}) [id: ${m.id}]:
237
237
  ${m.body || m.message}`).join("\n\n---\n\n");
238
238
  return { content: [{ type: "text", text: `${messages.length} message(s):
239
239
 
240
240
  ${text}` }] };
241
241
  }
242
242
  );
243
+ server.registerTool(
244
+ "lesa_reply_to_sender",
245
+ {
246
+ description: "Reply to a specific inbox message, routing back to the exact session that sent it (not broadcast). Use this instead of ldm_send_message when responding to something you saw in lesa_check_inbox. The message id is printed as [id: <uuid>] in the inbox output.\n\nIf the message id can't be found (already deleted, typo, etc.) the reply is still written with a best-effort target derived from the message sender field you pass in as fallback.",
247
+ inputSchema: {
248
+ messageId: z.string().describe("The inbox message id you are replying to (from the [id: ...] field in lesa_check_inbox output)"),
249
+ body: z.string().describe("Reply body"),
250
+ type: z.string().optional().default("chat").describe("Message type: chat, system, task (default: chat)")
251
+ }
252
+ },
253
+ async ({ messageId, body, type }) => {
254
+ try {
255
+ const { agentId, sessionName } = getSessionIdentity();
256
+ sendLdmMessage({
257
+ from: `${agentId}:${sessionName}`,
258
+ body,
259
+ type: type || "chat",
260
+ inReplyTo: messageId
261
+ });
262
+ return {
263
+ content: [{
264
+ type: "text",
265
+ text: `Replied to message ${messageId}. Routed back to the original sender (not broadcast).`
266
+ }]
267
+ };
268
+ } catch (err) {
269
+ return { content: [{ type: "text", text: `Error replying: ${err.message}` }], isError: true };
270
+ }
271
+ }
272
+ );
243
273
  server.registerTool(
244
274
  "ldm_send_message",
245
275
  {
package/lib/deploy.mjs CHANGED
@@ -245,9 +245,17 @@ function buildSourceInfo(repoPath, pkg) {
245
245
  hasInfo = true;
246
246
  }
247
247
 
248
- // If the repo path is inside ~/.ldm/tmp/, it was cloned from somewhere.
249
- // Try to get the remote URL from git.
250
- if (!source.repo) {
248
+ // If the repo path is itself a git working tree, trust its origin URL.
249
+ // Previously this ran git remote unconditionally, which walks up the
250
+ // directory tree. For npm-sourced installs extracted under ~/.ldm/tmp/
251
+ // (inside the tracked ~/.ldm repo), git happily returned the parent
252
+ // tracking repo's remote (wipcomputer/...-system-private) as the
253
+ // source for every extension. Registry source.repo was therefore
254
+ // unreliable. Now we only consult git if repoPath itself has a .git
255
+ // entry (directory for normal clones, file for worktrees). If it
256
+ // does not, we leave source.repo unset rather than capturing an
257
+ // ancestor's remote.
258
+ if (!source.repo && existsSync(join(repoPath, '.git'))) {
251
259
  try {
252
260
  const remote = execSync('git remote get-url origin 2>/dev/null', {
253
261
  cwd: repoPath,
@@ -882,6 +890,60 @@ function verifyOcConfig(pluginDirName) {
882
890
  }
883
891
  }
884
892
 
893
+ /**
894
+ * Phase 3b: remove a stale MCP registration for an extension whose current
895
+ * source no longer exposes an MCP interface. Keyed on path, not source
896
+ * metadata (buildSourceInfo is known to capture the parent repo's remote
897
+ * when extraction lands inside another git working tree).
898
+ *
899
+ * Removes the entry from ~/.claude.json#mcpServers if the args path
900
+ * resolves under LDM_EXTENSIONS/<toolName> or OC_EXTENSIONS/<toolName>.
901
+ * No-op if no entry exists or the path points elsewhere.
902
+ */
903
+ function unregisterStaleMCP(toolName) {
904
+ const ccUserPath = join(HOME, '.claude.json');
905
+ const ccUser = readJSON(ccUserPath);
906
+ const entry = ccUser?.mcpServers?.[toolName];
907
+ if (!entry) return;
908
+
909
+ const firstArg = Array.isArray(entry.args) ? entry.args[0] : null;
910
+ if (!firstArg || typeof firstArg !== 'string') return;
911
+
912
+ const ldmExt = join(LDM_EXTENSIONS, toolName);
913
+ const ocExt = join(OC_EXTENSIONS, toolName);
914
+ const pointsHere = firstArg.startsWith(ldmExt + '/') || firstArg.startsWith(ocExt + '/');
915
+ if (!pointsHere) return;
916
+
917
+ if (DRY_RUN) {
918
+ ok(`MCP: would unregister stale ${toolName} entry pointing at ${firstArg} (dry run)`);
919
+ return;
920
+ }
921
+
922
+ try {
923
+ execSync(`claude mcp remove ${toolName} --scope user`, { stdio: 'pipe' });
924
+ ok(`MCP: unregistered stale ${toolName} entry (source no longer exposes MCP)`);
925
+ } catch {
926
+ // Fallback: direct edit to ~/.claude.json
927
+ try {
928
+ const cfg = readJSON(ccUserPath) || {};
929
+ if (cfg.mcpServers && cfg.mcpServers[toolName]) {
930
+ delete cfg.mcpServers[toolName];
931
+ writeJSON(ccUserPath, cfg);
932
+ ok(`MCP: unregistered stale ${toolName} entry via direct .claude.json edit`);
933
+ }
934
+ } catch (e) {
935
+ log(`Warning: could not unregister stale MCP ${toolName}: ${e.message}`);
936
+ }
937
+ }
938
+
939
+ // Also clean OpenClaw side if installed.
940
+ try {
941
+ execSync(`openclaw mcp unset ${toolName}`, { stdio: 'pipe' });
942
+ } catch {
943
+ // Non-fatal. OpenClaw may not be installed, or may not have had this mcp.
944
+ }
945
+ }
946
+
885
947
  function registerMCP(repoPath, door, toolName) {
886
948
  let rawName = toolName || door.name || basename(repoPath);
887
949
  // Strip /tmp/ clone prefixes (ldm-install-, wip-install-)
@@ -894,6 +956,27 @@ function registerMCP(repoPath, door, toolName) {
894
956
  : existsSync(ldmFallbackPath) ? ldmFallbackPath
895
957
  : repoServerPath;
896
958
 
959
+ // Postcondition: the resolved entrypoint must exist and parse before we
960
+ // touch ~/.claude.json. Previously, if the published tarball did not
961
+ // contain the declared mcp-server file (see wip-1password 0.2.3-alpha.2
962
+ // bug writeup), we still wrote the registration and left a dangling
963
+ // "Failed to connect" entry that was invisible until the user ran
964
+ // `claude mcp list`. Fail loudly instead.
965
+ if (!existsSync(mcpPath)) {
966
+ fail(`MCP: ${name} registration aborted. Resolved path does not exist: ${mcpPath}`);
967
+ fail(`MCP: candidates checked: ${ldmServerPath}, ${ldmFallbackPath}, ${repoServerPath}`);
968
+ fail(`MCP: verify the published package includes "${door.file}" (check files array).`);
969
+ return false;
970
+ }
971
+ try {
972
+ execSync(`node --check "${mcpPath}"`, { stdio: 'pipe', timeout: 5000 });
973
+ } catch (e) {
974
+ const stderr = (e && e.stderr && e.stderr.toString && e.stderr.toString().trim()) || (e && e.message) || 'unknown error';
975
+ fail(`MCP: ${name} registration aborted. Entrypoint failed node --check: ${mcpPath}`);
976
+ fail(`MCP: ${stderr}`);
977
+ return false;
978
+ }
979
+
897
980
  // Check ~/.claude.json (user-level MCP)
898
981
  const ccUserPath = join(HOME, '.claude.json');
899
982
  const ccUser = readJSON(ccUserPath);
@@ -1272,6 +1355,12 @@ export function installSingleTool(toolPath) {
1272
1355
  } else {
1273
1356
  skip(`MCP: ${toolName} not enabled. Run: ldm enable ${toolName}`);
1274
1357
  }
1358
+ } else {
1359
+ // Phase 3b: source no longer exposes an MCP interface (file renamed,
1360
+ // moved to src/, removed, etc). If a prior install registered an MCP
1361
+ // whose args point into this extension's directory, un-register it so
1362
+ // claude mcp list does not keep a dangling "Failed to connect" entry.
1363
+ unregisterStaleMCP(toolName);
1275
1364
  }
1276
1365
 
1277
1366
  if (interfaces.claudeCodeHook) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.78",
3
+ "version": "0.4.80",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -261,6 +261,23 @@ cd ~/.ldm/extensions/{name} && npm install --omit=dev
261
261
  openclaw gateway restart
262
262
  ```
263
263
 
264
+ ## Bridge: Reply Routing (lesa-bridge 0.4.1+)
265
+
266
+ The bridge's file inbox (`~/.ldm/messages/`) supports three routing modes:
267
+
268
+ | `to` field | Delivered to |
269
+ |---|---|
270
+ | `agent:session` | Exactly one session (unicast) |
271
+ | `agent:*` | All sessions of that agent (explicit broadcast) |
272
+ | `agent` (no colon) | All sessions of that agent (legacy broadcast, kept for compat) |
273
+ | `*` or `all` | All agents, all sessions |
274
+
275
+ **Reply-to-sender pattern (use this for replies).** When responding to a message you read via `lesa_check_inbox`, call `lesa_reply_to_sender({ messageId, body })` with the id from `[id: <uuid>]` in the inbox output. The bridge auto-resolves `to` to the original sender's fully-qualified identity (`agent:session`), so replies land at exactly one session instead of waking every cc-mini session on the machine.
276
+
277
+ For non-reply sends (initiating a thread), use `ldm_send_message` with an explicit `agent:session` target, or `lesa_send_message` for OpenClaw (Lēsa) which goes through the gateway pipeline.
278
+
279
+ Avoid agent-only targets (`to: "cc-mini"`) for replies. They still work (broadcast) but burn a turn in every idle cc-mini session that happens to be open. Reply-to-sender is the cheap, obvious, correct path.
280
+
264
281
  ## Branch Protection Audit
265
282
 
266
283
  Enforced on all 64 repos on 2026-02-20, re-audited 2026-02-27 (18 repos had drifted or were new). No force pushes to main. No direct pushes. No exceptions. The `lesaai` account is not exempt.
@@ -65,6 +65,12 @@ export interface InboxMessage {
65
65
  message?: string; // legacy compat: alias for body
66
66
  timestamp: string;
67
67
  read: boolean;
68
+ // Optional: id of the message this is a reply to. When present on a
69
+ // pushInbox call and `to` is missing or agent-only, the bridge looks up
70
+ // the referenced message and copies its `from` into this message's `to`
71
+ // so replies land at the specific session that sent the original, not
72
+ // broadcast to every session of the agent.
73
+ inReplyTo?: string;
68
74
  }
69
75
 
70
76
  export interface ConversationResult {
@@ -287,22 +293,61 @@ function messageMatchesSession(msgTo: string, agentId: string, sessionName: stri
287
293
  return target.session === sessionName;
288
294
  }
289
295
 
296
+ /**
297
+ * Look up a message by id in the inbox or processed dir. Returns null if
298
+ * not found. Used by reply-to-sender routing.
299
+ */
300
+ export function findMessageById(id: string): InboxMessage | null {
301
+ if (!id) return null;
302
+ for (const dir of [MESSAGES_DIR, PROCESSED_DIR]) {
303
+ const candidate = join(dir, `${id}.json`);
304
+ if (!existsSync(candidate)) continue;
305
+ try {
306
+ const data = JSON.parse(readFileSync(candidate, "utf-8")) as InboxMessage;
307
+ return data;
308
+ } catch {}
309
+ }
310
+ return null;
311
+ }
312
+
290
313
  /**
291
314
  * Write a message to the file-based inbox.
292
315
  * Creates a JSON file at ~/.ldm/messages/{uuid}.json.
316
+ *
317
+ * Reply-to-sender routing (added 2026-04-20):
318
+ * If `inReplyTo` is set AND `to` is missing or agent-only (no colon),
319
+ * the bridge looks up the referenced message and copies its `from` into
320
+ * this message's `to`. This makes replies land at the specific session
321
+ * that sent the original, rather than broadcasting to every session of
322
+ * the agent (which is what Apr 10's Option 1 shipped as a safety net).
323
+ * Callers that explicitly want broadcast can still use
324
+ * `to: "<agent>:*"` or `to: "*"`.
293
325
  */
294
- export function pushInbox(msg: { from: string; message?: string; body?: string; to?: string; type?: string }): number {
326
+ export function pushInbox(msg: { from: string; message?: string; body?: string; to?: string; type?: string; inReplyTo?: string }): number {
295
327
  try {
296
328
  mkdirSync(MESSAGES_DIR, { recursive: true });
297
329
  const id = randomUUID();
330
+
331
+ // Resolve reply target from inReplyTo if applicable.
332
+ let resolvedTo = msg.to;
333
+ const explicitBroadcast = resolvedTo === "*" || resolvedTo === "all" || (resolvedTo && resolvedTo.endsWith(":*"));
334
+ const agentOnly = resolvedTo && !resolvedTo.includes(":") && !explicitBroadcast;
335
+ if (msg.inReplyTo && (!resolvedTo || agentOnly)) {
336
+ const original = findMessageById(msg.inReplyTo);
337
+ if (original && original.from) {
338
+ resolvedTo = original.from;
339
+ }
340
+ }
341
+
298
342
  const data: InboxMessage = {
299
343
  id,
300
344
  type: msg.type || "chat",
301
345
  from: msg.from || "unknown",
302
- to: msg.to || `${_sessionAgentId}:${_sessionName}`,
346
+ to: resolvedTo || `${_sessionAgentId}:${_sessionName}`,
303
347
  body: msg.body || msg.message || "",
304
348
  timestamp: new Date().toISOString(),
305
349
  read: false,
350
+ ...(msg.inReplyTo ? { inReplyTo: msg.inReplyTo } : {}),
306
351
  };
307
352
  writeFileSync(join(MESSAGES_DIR, `${id}.json`), JSON.stringify(data, null, 2) + "\n");
308
353
 
@@ -416,21 +461,45 @@ export function inboxCountBySession(): Record<string, number> {
416
461
  */
417
462
  export function sendLdmMessage(opts: {
418
463
  from?: string;
419
- to: string;
464
+ to?: string;
420
465
  body: string;
421
466
  type?: string;
467
+ // Reply-to-sender routing (added 2026-04-20). If provided and `to` is
468
+ // missing or agent-only (no session suffix), `to` is auto-resolved from
469
+ // the referenced message's `from`, so the reply lands at exactly the
470
+ // session that sent the original. See
471
+ // ai/product/bugs/bridge/2026-04-20--cc-mini--bridge-reply-to-sender-routing.md
472
+ inReplyTo?: string;
422
473
  }): string | null {
423
474
  try {
424
475
  mkdirSync(MESSAGES_DIR, { recursive: true });
425
476
  const id = randomUUID();
477
+
478
+ // Resolve reply target from inReplyTo when applicable.
479
+ let resolvedTo = opts.to;
480
+ const explicitBroadcast = resolvedTo === "*" || resolvedTo === "all" || (resolvedTo && resolvedTo.endsWith(":*"));
481
+ const agentOnly = resolvedTo && !resolvedTo.includes(":") && !explicitBroadcast;
482
+ if (opts.inReplyTo && (!resolvedTo || agentOnly)) {
483
+ const original = findMessageById(opts.inReplyTo);
484
+ if (original && original.from) {
485
+ resolvedTo = original.from;
486
+ }
487
+ }
488
+ if (!resolvedTo) {
489
+ // No target and no inReplyTo to resolve from. Nothing safe to do
490
+ // except a no-op. Caller is at fault; return null to signal.
491
+ return null;
492
+ }
493
+
426
494
  const data: InboxMessage = {
427
495
  id,
428
496
  type: opts.type || "chat",
429
497
  from: opts.from || `${_sessionAgentId}:${_sessionName}`,
430
- to: opts.to,
498
+ to: resolvedTo,
431
499
  body: opts.body,
432
500
  timestamp: new Date().toISOString(),
433
501
  read: false,
502
+ ...(opts.inReplyTo ? { inReplyTo: opts.inReplyTo } : {}),
434
503
  };
435
504
  writeFileSync(join(MESSAGES_DIR, `${id}.json`), JSON.stringify(data, null, 2) + "\n");
436
505
  return id;
@@ -303,7 +303,9 @@ server.registerTool(
303
303
  description:
304
304
  "Check for pending messages in the file-based inbox (~/.ldm/messages/). " +
305
305
  "Messages can come from OpenClaw agents, other Claude Code sessions, or CLI. " +
306
- "Returns all pending messages for this session and marks them as read.",
306
+ "Returns all pending messages for this session and marks them as read. " +
307
+ "Each entry includes [id: <uuid>] so you can pass it to lesa_reply_to_sender " +
308
+ "when replying to a specific message.",
307
309
  inputSchema: {},
308
310
  },
309
311
  async () => {
@@ -314,13 +316,57 @@ server.registerTool(
314
316
  }
315
317
 
316
318
  const text = messages
317
- .map((m) => `**${m.from}** [${m.type}] (${m.timestamp}):\n${m.body || m.message}`)
319
+ .map((m) => `**${m.from}** [${m.type}] (${m.timestamp}) [id: ${m.id}]:\n${m.body || m.message}`)
318
320
  .join("\n\n---\n\n");
319
321
 
320
322
  return { content: [{ type: "text" as const, text: `${messages.length} message(s):\n\n${text}` }] };
321
323
  }
322
324
  );
323
325
 
326
+ // Tool 5b: Reply to a specific message, routing back to the original sender.
327
+ // Added 2026-04-20 to fix the "agent-only reply broadcasts to every session"
328
+ // footgun. Caller passes the message id from lesa_check_inbox output; the
329
+ // bridge resolves `to` to the original sender's fully-qualified identity,
330
+ // so replies land at exactly one session instead of every session of the
331
+ // agent. See ai/product/bugs/bridge/2026-04-20--cc-mini--bridge-reply-to-sender-routing.md
332
+ server.registerTool(
333
+ "lesa_reply_to_sender",
334
+ {
335
+ description:
336
+ "Reply to a specific inbox message, routing back to the exact session " +
337
+ "that sent it (not broadcast). Use this instead of ldm_send_message when " +
338
+ "responding to something you saw in lesa_check_inbox. The message id is " +
339
+ "printed as [id: <uuid>] in the inbox output.\n\n" +
340
+ "If the message id can't be found (already deleted, typo, etc.) the reply " +
341
+ "is still written with a best-effort target derived from the message sender " +
342
+ "field you pass in as fallback.",
343
+ inputSchema: {
344
+ messageId: z.string().describe("The inbox message id you are replying to (from the [id: ...] field in lesa_check_inbox output)"),
345
+ body: z.string().describe("Reply body"),
346
+ type: z.string().optional().default("chat").describe("Message type: chat, system, task (default: chat)"),
347
+ },
348
+ },
349
+ async ({ messageId, body, type }) => {
350
+ try {
351
+ const { agentId, sessionName } = getSessionIdentity();
352
+ sendLdmMessage({
353
+ from: `${agentId}:${sessionName}`,
354
+ body,
355
+ type: type || "chat",
356
+ inReplyTo: messageId,
357
+ });
358
+ return {
359
+ content: [{
360
+ type: "text" as const,
361
+ text: `Replied to message ${messageId}. Routed back to the original sender (not broadcast).`,
362
+ }],
363
+ };
364
+ } catch (err: any) {
365
+ return { content: [{ type: "text" as const, text: `Error replying: ${err.message}` }], isError: true };
366
+ }
367
+ }
368
+ );
369
+
324
370
  // Tool 6: Send message to any agent via file-based inbox (Phase 4)
325
371
  server.registerTool(
326
372
  "ldm_send_message",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/ldm-bridge",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "WIP Bridge ... agent-to-agent communication, memory search, skill bridging. Core module of LDM OS.",