@wipcomputer/wip-ldm-os 0.4.77 → 0.4.79

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.77"
12
+ version: "0.4.79"
13
13
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
14
14
  author: "Parker Todd Brooks"
15
15
  category: infrastructure
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.77",
3
+ "version": "0.4.79",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -261,10 +261,84 @@ 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.
267
284
 
285
+ ## Branch Guard: Runtime Enforcement (wip-branch-guard v1.9.80+)
286
+
287
+ The branch guard runs as a PreToolUse hook on every Claude Code tool call and as an OpenClaw plugin for Lēsa. Same rules, same deny messages on both sides. Enforces three layers on top of the standard branch/worktree check.
288
+
289
+ ### Layer 1 ... write gate
290
+
291
+ - On main branch: Write/Edit/NotebookEdit denied. Bash writes denied.
292
+ - On a feature branch NOT in a worktree: writes denied.
293
+ - On a feature branch in a worktree: writes allowed (baseline workflow).
294
+ - Shared-state paths (`~/.claude/plans/`, `workspace/*`, `~/.ldm/shared/`, `~/.openclaw/workspace/`, etc.) always allowed regardless of branch.
295
+
296
+ ### Layer 2 ... destructive-command block
297
+
298
+ Always denied, any branch: `git clean -f`, `git checkout -- <path>`, `git checkout .`, `git stash drop/pop/clear`, `git reset --hard`, `git restore <path>` (`--staged` is safe). Plus code-execution bypass patterns (`python -c "open().write()"`, `node -e "writeFile()"`).
299
+
300
+ Denied on any branch: `--no-verify`, `git push --force` (use `--force-with-lease`).
301
+
302
+ ### Layer 3 ... session-level gates (new 2026-04-20)
303
+
304
+ 1. **Onboarding-before-first-write.** Before the first Write/Edit to a repo in a session, the guard requires you to have Read (via the Read tool) `README.md`, `CLAUDE.md`, and any `*RUNBOOK*.md`/`*LANDMINES*.md`/`WORKFLOW*.md` at repo root. State auto-populates from Read calls. 2-hour TTL once onboarded. Override: `LDM_GUARD_SKIP_ONBOARDING=<repo>` or `=1`.
305
+
306
+ 2. **Recently-blocked-file tracking.** If the guard denies a file via Write/Edit, retrying via a different tool on the same path re-denies with "equivalent-action bypass" context. Last 20 denials in a 1-hour rolling window. Override: `LDM_GUARD_ACK_BLOCKED_FILE=<path>`.
307
+
308
+ 3. **External-PR create guard.** `gh pr create --repo <owner>/<repo>` and `gh api repos/<owner>/<repo>/pulls -X POST` are denied if `<owner>` is not `wipcomputer`. Triggered by the 2026-04-18 PR #89 incident. `gh pr view/list/merge/edit` and `gh api .../issues` pass through. Override: `LDM_GUARD_UPSTREAM_PR_APPROVED=<owner>/<repo>` or `=1`.
309
+
310
+ ### Override env vars
311
+
312
+ All overrides are audited. Every use appends to `~/.ldm/state/bypass-audit.jsonl` as `{kind, ts, session_id, tool, path, via, reason}`. Log rotates at 50 MB to dated archives.
313
+
314
+ | Env var | Purpose | Shape |
315
+ |---|---|---|
316
+ | `LDM_GUARD_SKIP_ONBOARDING` | Skip onboarding gate | `<repo-path>` or `1` |
317
+ | `LDM_GUARD_ACK_BLOCKED_FILE` | Acknowledge a prior block | `<file-path>` or `1` |
318
+ | `LDM_GUARD_UPSTREAM_PR_APPROVED` | Parker-approved external PR | `<owner>/<repo>` or `1` |
319
+ | `LDM_GUARD_APPROVAL_BACKEND` | Backend selector | `env` (default), future: `bridge`, `kaleidoscope-biometric` |
320
+ | `LDM_GUARD_STATE_DIR` | State file location | dir path (test isolation only) |
321
+
322
+ Default stance: don't bypass. Read the block message. It tells you what to do.
323
+
324
+ ### Expected first-write ritual
325
+
326
+ For any repo new to your session:
327
+
328
+ ```
329
+ 1. git rev-parse --show-toplevel # confirm the repo
330
+ 2. Read README.md # via Read tool
331
+ 3. Read CLAUDE.md # if present
332
+ 4. Read RUNBOOK / LANDMINES / WORKFLOW # if present at root
333
+ 5. Proceed with Write/Edit
334
+ ```
335
+
336
+ State persists at `~/.ldm/state/guard-session.json`. Same session + same repo = onboarded for 2h. New session or TTL expires = re-onboard.
337
+
338
+ ### Bypass audit trail
339
+
340
+ Every deny and every approved bypass is in `~/.ldm/state/bypass-audit.jsonl`. Append-only JSON Lines. Tail it during post-mortems; rotate archives are `bypass-audit.jsonl.YYYY-MM-DD`.
341
+
268
342
  ## Worktree Workflow (WIP-specific)
269
343
 
270
344
  Same as the public Dev Guide section, plus:
@@ -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.",