@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 +1 -1
- package/dist/bridge/{chunk-7NH6JBIO.js → chunk-O65O6CCM.js} +41 -4
- package/dist/bridge/cli.js +1 -1
- package/dist/bridge/core.d.ts +19 -2
- package/dist/bridge/core.js +3 -1
- package/dist/bridge/mcp-server.js +33 -3
- package/package.json +1 -1
- package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +74 -0
- package/src/bridge/core.ts +73 -4
- package/src/bridge/mcp-server.ts +48 -2
- package/src/bridge/package.json +1 -1
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.
|
|
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:
|
|
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:
|
|
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,
|
package/dist/bridge/cli.js
CHANGED
package/dist/bridge/core.d.ts
CHANGED
|
@@ -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
|
|
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 };
|
package/dist/bridge/core.js
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
@@ -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:
|
package/src/bridge/core.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
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;
|
package/src/bridge/mcp-server.ts
CHANGED
|
@@ -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",
|
package/src/bridge/package.json
CHANGED