@wipcomputer/wip-ldm-os 0.4.66 → 0.4.68

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.66"
12
+ version: "0.4.68"
13
13
  homepage: "https://github.com/wipcomputer/wip-ldm-os"
14
14
  author: "Parker Todd Brooks"
15
15
  category: infrastructure
package/bin/scaffold.sh CHANGED
@@ -10,8 +10,11 @@ CC_HOME="${LDM_HOME}/agents/cc"
10
10
  SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
11
11
  TEMPLATES="${SCRIPT_DIR}/templates/cc"
12
12
 
13
- # Existing soul files
14
- CC_DOCS="${HOME}/Documents/wipcomputer--mac-mini-01/staff/Parker/Claude Code - Mini/documents"
13
+ # Resolve workspace from LDM config or default
14
+ WORKSPACE=$(python3 -c "import json; print(json.load(open('$HOME/.ldm/config.json')).get('workspace','$HOME/wipcomputerinc'))" 2>/dev/null || echo "$HOME/wipcomputerinc")
15
+
16
+ # Existing soul files (now under workspace/team/cc-mini/)
17
+ CC_DOCS="${WORKSPACE}/team/cc-mini/documents"
15
18
  CC_SOUL="${CC_DOCS}/cc-soul"
16
19
 
17
20
  echo "=== LDM OS Scaffold ==="
@@ -1,9 +1,10 @@
1
1
  // core.ts
2
2
  import { execSync, exec } from "child_process";
3
- import { readdirSync, readFileSync, existsSync, statSync } from "fs";
3
+ import { readdirSync, readFileSync, writeFileSync, existsSync, statSync, mkdirSync, renameSync, unlinkSync } from "fs";
4
4
  import { join, relative, resolve } from "path";
5
5
  import { homedir } from "os";
6
6
  import { promisify } from "util";
7
+ import { randomUUID } from "crypto";
7
8
  var execAsync = promisify(exec);
8
9
  var HOME = process.env.HOME || homedir();
9
10
  var LDM_ROOT = process.env.LDM_ROOT || join(HOME, ".ldm");
@@ -82,18 +83,180 @@ function resolveGatewayConfig(openclawDir) {
82
83
  cachedGatewayConfig = { token, port };
83
84
  return cachedGatewayConfig;
84
85
  }
85
- var inboxQueue = [];
86
+ var MESSAGES_DIR = join(LDM_ROOT, "messages");
87
+ var PROCESSED_DIR = join(MESSAGES_DIR, "_processed");
88
+ var _sessionAgentId = "cc-mini";
89
+ var _sessionName = process.env.LDM_SESSION_NAME || "default";
90
+ function setSessionIdentity(agentId, sessionName) {
91
+ _sessionAgentId = agentId;
92
+ _sessionName = sessionName;
93
+ }
94
+ function getSessionIdentity() {
95
+ return { agentId: _sessionAgentId, sessionName: _sessionName };
96
+ }
97
+ function parseTarget(to) {
98
+ if (to === "*") return { agent: "*", session: "*" };
99
+ const colonIdx = to.indexOf(":");
100
+ if (colonIdx === -1) return { agent: to, session: "default" };
101
+ return { agent: to.slice(0, colonIdx), session: to.slice(colonIdx + 1) };
102
+ }
103
+ function messageMatchesSession(msgTo, agentId, sessionName) {
104
+ if (msgTo === "*" || msgTo === "all") return true;
105
+ const target = parseTarget(msgTo);
106
+ if (target.agent !== "*" && target.agent !== agentId) return false;
107
+ if (target.session === "*") return true;
108
+ return target.session === sessionName;
109
+ }
86
110
  function pushInbox(msg) {
87
- inboxQueue.push(msg);
88
- return inboxQueue.length;
111
+ try {
112
+ mkdirSync(MESSAGES_DIR, { recursive: true });
113
+ const id = randomUUID();
114
+ const data = {
115
+ id,
116
+ type: msg.type || "chat",
117
+ from: msg.from || "unknown",
118
+ to: msg.to || `${_sessionAgentId}:${_sessionName}`,
119
+ body: msg.body || msg.message || "",
120
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
121
+ read: false
122
+ };
123
+ writeFileSync(join(MESSAGES_DIR, `${id}.json`), JSON.stringify(data, null, 2) + "\n");
124
+ return inboxCount();
125
+ } catch {
126
+ return 0;
127
+ }
89
128
  }
90
129
  function drainInbox() {
91
- const messages = [...inboxQueue];
92
- inboxQueue.length = 0;
93
- return messages;
130
+ try {
131
+ if (!existsSync(MESSAGES_DIR)) return [];
132
+ const files = readdirSync(MESSAGES_DIR).filter((f) => f.endsWith(".json"));
133
+ const messages = [];
134
+ for (const file of files) {
135
+ const filePath = join(MESSAGES_DIR, file);
136
+ try {
137
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
138
+ if (!messageMatchesSession(data.to, _sessionAgentId, _sessionName)) continue;
139
+ if (!data.body && data.message) data.body = data.message;
140
+ messages.push(data);
141
+ try {
142
+ mkdirSync(PROCESSED_DIR, { recursive: true });
143
+ renameSync(filePath, join(PROCESSED_DIR, file));
144
+ } catch {
145
+ try {
146
+ unlinkSync(filePath);
147
+ } catch {
148
+ }
149
+ }
150
+ } catch {
151
+ }
152
+ }
153
+ messages.sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || ""));
154
+ return messages;
155
+ } catch {
156
+ return [];
157
+ }
94
158
  }
95
159
  function inboxCount() {
96
- return inboxQueue.length;
160
+ try {
161
+ if (!existsSync(MESSAGES_DIR)) return 0;
162
+ const files = readdirSync(MESSAGES_DIR).filter((f) => f.endsWith(".json"));
163
+ let count = 0;
164
+ for (const file of files) {
165
+ try {
166
+ const data = JSON.parse(readFileSync(join(MESSAGES_DIR, file), "utf-8"));
167
+ if (messageMatchesSession(data.to, _sessionAgentId, _sessionName)) count++;
168
+ } catch {
169
+ }
170
+ }
171
+ return count;
172
+ } catch {
173
+ return 0;
174
+ }
175
+ }
176
+ function inboxCountBySession() {
177
+ const counts = {};
178
+ try {
179
+ if (!existsSync(MESSAGES_DIR)) return counts;
180
+ const files = readdirSync(MESSAGES_DIR).filter((f) => f.endsWith(".json"));
181
+ for (const file of files) {
182
+ try {
183
+ const data = JSON.parse(readFileSync(join(MESSAGES_DIR, file), "utf-8"));
184
+ const to = data.to || "unknown";
185
+ counts[to] = (counts[to] || 0) + 1;
186
+ } catch {
187
+ }
188
+ }
189
+ } catch {
190
+ }
191
+ return counts;
192
+ }
193
+ function sendLdmMessage(opts) {
194
+ try {
195
+ mkdirSync(MESSAGES_DIR, { recursive: true });
196
+ const id = randomUUID();
197
+ const data = {
198
+ id,
199
+ type: opts.type || "chat",
200
+ from: opts.from || `${_sessionAgentId}:${_sessionName}`,
201
+ to: opts.to,
202
+ body: opts.body,
203
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
204
+ read: false
205
+ };
206
+ writeFileSync(join(MESSAGES_DIR, `${id}.json`), JSON.stringify(data, null, 2) + "\n");
207
+ return id;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+ var SESSIONS_DIR = join(LDM_ROOT, "sessions");
213
+ function registerBridgeSession() {
214
+ try {
215
+ mkdirSync(SESSIONS_DIR, { recursive: true });
216
+ const fileName = `${_sessionAgentId}--${_sessionName}.json`;
217
+ const data = {
218
+ name: _sessionName,
219
+ agentId: _sessionAgentId,
220
+ pid: process.pid,
221
+ startTime: (/* @__PURE__ */ new Date()).toISOString(),
222
+ cwd: process.cwd(),
223
+ alive: true
224
+ };
225
+ writeFileSync(join(SESSIONS_DIR, fileName), JSON.stringify(data, null, 2) + "\n");
226
+ return data;
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+ function listActiveSessions(agentFilter) {
232
+ try {
233
+ if (!existsSync(SESSIONS_DIR)) return [];
234
+ const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json"));
235
+ const sessions = [];
236
+ for (const file of files) {
237
+ try {
238
+ const filePath = join(SESSIONS_DIR, file);
239
+ const data = JSON.parse(readFileSync(filePath, "utf-8"));
240
+ let alive = false;
241
+ try {
242
+ process.kill(data.pid, 0);
243
+ alive = true;
244
+ } catch {
245
+ try {
246
+ unlinkSync(filePath);
247
+ } catch {
248
+ }
249
+ continue;
250
+ }
251
+ if (agentFilter && data.agentId !== agentFilter) continue;
252
+ sessions.push({ ...data, alive });
253
+ } catch {
254
+ }
255
+ }
256
+ return sessions;
257
+ } catch {
258
+ return [];
259
+ }
97
260
  }
98
261
  async function sendMessage(openclawDir, message, options) {
99
262
  const { token, port } = resolveGatewayConfig(openclawDir);
@@ -409,9 +572,15 @@ export {
409
572
  resolveConfigMulti,
410
573
  resolveApiKey,
411
574
  resolveGatewayConfig,
575
+ setSessionIdentity,
576
+ getSessionIdentity,
412
577
  pushInbox,
413
578
  drainInbox,
414
579
  inboxCount,
580
+ inboxCountBySession,
581
+ sendLdmMessage,
582
+ registerBridgeSession,
583
+ listActiveSessions,
415
584
  sendMessage,
416
585
  getQueryEmbedding,
417
586
  blobToEmbedding,
@@ -8,7 +8,7 @@ import {
8
8
  searchConversations,
9
9
  searchWorkspace,
10
10
  sendMessage
11
- } from "./chunk-5EOEBBUV.js";
11
+ } from "./chunk-LF7EMFBY.js";
12
12
 
13
13
  // cli.ts
14
14
  import { existsSync, statSync } from "fs";
@@ -12,9 +12,14 @@ interface GatewayConfig {
12
12
  port: number;
13
13
  }
14
14
  interface InboxMessage {
15
+ id: string;
16
+ type: string;
15
17
  from: string;
16
- message: string;
18
+ to: string;
19
+ body: string;
20
+ message?: string;
17
21
  timestamp: string;
22
+ read: boolean;
18
23
  }
19
24
  interface ConversationResult {
20
25
  text: string;
@@ -39,9 +44,65 @@ declare function resolveConfig(overrides?: Partial<BridgeConfig>): BridgeConfig;
39
44
  declare function resolveConfigMulti(overrides?: Partial<BridgeConfig>): BridgeConfig;
40
45
  declare function resolveApiKey(openclawDir: string): string | null;
41
46
  declare function resolveGatewayConfig(openclawDir: string): GatewayConfig;
42
- declare function pushInbox(msg: InboxMessage): number;
47
+ declare function setSessionIdentity(agentId: string, sessionName: string): void;
48
+ declare function getSessionIdentity(): {
49
+ agentId: string;
50
+ sessionName: string;
51
+ };
52
+ /**
53
+ * Write a message to the file-based inbox.
54
+ * Creates a JSON file at ~/.ldm/messages/{uuid}.json.
55
+ */
56
+ declare function pushInbox(msg: {
57
+ from: string;
58
+ message?: string;
59
+ body?: string;
60
+ to?: string;
61
+ type?: string;
62
+ }): number;
63
+ /**
64
+ * Read and drain all messages for this session from the inbox.
65
+ * Moves processed messages to ~/.ldm/messages/_processed/.
66
+ */
43
67
  declare function drainInbox(): InboxMessage[];
68
+ /**
69
+ * Count pending messages for this session without draining.
70
+ */
44
71
  declare function inboxCount(): number;
72
+ /**
73
+ * Get pending message counts broken down by session.
74
+ * Used by GET /status to show per-session counts.
75
+ */
76
+ declare function inboxCountBySession(): Record<string, number>;
77
+ /**
78
+ * Send a message to another agent or session via the file-based inbox.
79
+ * Phase 4: Cross-agent messaging. Works for any agent, any session.
80
+ * This is the file-based path. For OpenClaw agents, use sendMessage() (gateway).
81
+ */
82
+ declare function sendLdmMessage(opts: {
83
+ from?: string;
84
+ to: string;
85
+ body: string;
86
+ type?: string;
87
+ }): string | null;
88
+ interface SessionInfo {
89
+ name: string;
90
+ agentId: string;
91
+ pid: number;
92
+ startTime: string;
93
+ cwd: string;
94
+ alive: boolean;
95
+ meta?: Record<string, unknown>;
96
+ }
97
+ /**
98
+ * Register this bridge session in ~/.ldm/sessions/.
99
+ * Uses the agent--session naming convention.
100
+ */
101
+ declare function registerBridgeSession(): SessionInfo | null;
102
+ /**
103
+ * List active sessions. Validates PID liveness and cleans stale entries.
104
+ */
105
+ declare function listActiveSessions(agentFilter?: string): SessionInfo[];
45
106
  declare function sendMessage(openclawDir: string, message: string, options?: {
46
107
  agentId?: string;
47
108
  user?: string;
@@ -71,4 +132,4 @@ declare function discoverSkills(openclawDir: string): SkillInfo[];
71
132
  declare function executeSkillScript(skillDir: string, scripts: string[], scriptName: string | undefined, args: string): Promise<string>;
72
133
  declare function readWorkspaceFile(workspaceDir: string, filePath: string): WorkspaceFileResult;
73
134
 
74
- export { type BridgeConfig, type ConversationResult, type GatewayConfig, type InboxMessage, LDM_ROOT, type SkillInfo, type WorkspaceFileResult, type WorkspaceSearchResult, blobToEmbedding, cosineSimilarity, discoverSkills, drainInbox, executeSkillScript, findMarkdownFiles, getQueryEmbedding, inboxCount, pushInbox, readWorkspaceFile, resolveApiKey, resolveConfig, resolveConfigMulti, resolveGatewayConfig, searchConversations, searchWorkspace, sendMessage };
135
+ 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, registerBridgeSession, resolveApiKey, resolveConfig, resolveConfigMulti, resolveGatewayConfig, searchConversations, searchWorkspace, sendLdmMessage, sendMessage, setSessionIdentity };
@@ -7,17 +7,23 @@ import {
7
7
  executeSkillScript,
8
8
  findMarkdownFiles,
9
9
  getQueryEmbedding,
10
+ getSessionIdentity,
10
11
  inboxCount,
12
+ inboxCountBySession,
13
+ listActiveSessions,
11
14
  pushInbox,
12
15
  readWorkspaceFile,
16
+ registerBridgeSession,
13
17
  resolveApiKey,
14
18
  resolveConfig,
15
19
  resolveConfigMulti,
16
20
  resolveGatewayConfig,
17
21
  searchConversations,
18
22
  searchWorkspace,
19
- sendMessage
20
- } from "./chunk-5EOEBBUV.js";
23
+ sendLdmMessage,
24
+ sendMessage,
25
+ setSessionIdentity
26
+ } from "./chunk-LF7EMFBY.js";
21
27
  export {
22
28
  LDM_ROOT,
23
29
  blobToEmbedding,
@@ -27,14 +33,20 @@ export {
27
33
  executeSkillScript,
28
34
  findMarkdownFiles,
29
35
  getQueryEmbedding,
36
+ getSessionIdentity,
30
37
  inboxCount,
38
+ inboxCountBySession,
39
+ listActiveSessions,
31
40
  pushInbox,
32
41
  readWorkspaceFile,
42
+ registerBridgeSession,
33
43
  resolveApiKey,
34
44
  resolveConfig,
35
45
  resolveConfigMulti,
36
46
  resolveGatewayConfig,
37
47
  searchConversations,
38
48
  searchWorkspace,
39
- sendMessage
49
+ sendLdmMessage,
50
+ sendMessage,
51
+ setSessionIdentity
40
52
  };
@@ -2,14 +2,20 @@ import {
2
2
  discoverSkills,
3
3
  drainInbox,
4
4
  executeSkillScript,
5
+ getSessionIdentity,
5
6
  inboxCount,
7
+ inboxCountBySession,
8
+ listActiveSessions,
6
9
  pushInbox,
7
10
  readWorkspaceFile,
11
+ registerBridgeSession,
8
12
  resolveConfig,
9
13
  searchConversations,
10
14
  searchWorkspace,
11
- sendMessage
12
- } from "./chunk-5EOEBBUV.js";
15
+ sendLdmMessage,
16
+ sendMessage,
17
+ setSessionIdentity
18
+ } from "./chunk-LF7EMFBY.js";
13
19
 
14
20
  // mcp-server.ts
15
21
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -17,9 +23,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
17
23
  import { createServer } from "http";
18
24
  import { appendFileSync, mkdirSync } from "fs";
19
25
  import { join } from "path";
26
+ import { homedir } from "os";
20
27
  import { z } from "zod";
21
28
  var config = resolveConfig();
22
- var METRICS_DIR = join(process.env.HOME || "/Users/lesa", ".openclaw", "memory");
29
+ var METRICS_DIR = join(process.env.HOME || homedir(), ".openclaw", "memory");
23
30
  var METRICS_PATH = join(METRICS_DIR, "search-metrics.jsonl");
24
31
  function logSearchMetric(tool, query, resultCount) {
25
32
  try {
@@ -53,18 +60,20 @@ function startInboxServer(cfg) {
53
60
  if (req.method === "POST" && req.url === "/message") {
54
61
  try {
55
62
  const body = JSON.parse(await readBody(req));
56
- const msg = {
63
+ const { agentId, sessionName } = getSessionIdentity();
64
+ const queued = pushInbox({
57
65
  from: body.from || "agent",
58
- message: body.message,
59
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
60
- };
61
- const queued = pushInbox(msg);
62
- console.error(`wip-bridge inbox: message from ${msg.from}`);
66
+ body: body.body || body.message || "",
67
+ to: body.to || `${agentId}:${sessionName}`,
68
+ type: body.type || "chat"
69
+ });
70
+ const messageBody = body.body || body.message || "";
71
+ console.error(`wip-bridge inbox: message from ${body.from || "agent"} to ${body.to || "default"}`);
63
72
  try {
64
73
  server.sendLoggingMessage({
65
74
  level: "info",
66
75
  logger: "wip-bridge",
67
- data: `[OpenClaw \u2192 Claude Code] ${msg.from}: ${msg.message}`
76
+ data: `[inbox] ${body.from || "agent"}: ${messageBody}`
68
77
  });
69
78
  } catch {
70
79
  }
@@ -77,8 +86,16 @@ function startInboxServer(cfg) {
77
86
  return;
78
87
  }
79
88
  if (req.method === "GET" && req.url === "/status") {
89
+ const pending = inboxCount();
90
+ const bySession = inboxCountBySession();
80
91
  res.writeHead(200, { "Content-Type": "application/json" });
81
- res.end(JSON.stringify({ ok: true, pending: inboxCount() }));
92
+ res.end(JSON.stringify({ ok: true, pending, bySession }));
93
+ return;
94
+ }
95
+ if (req.method === "GET" && req.url === "/sessions") {
96
+ const sessions = listActiveSessions();
97
+ res.writeHead(200, { "Content-Type": "application/json" });
98
+ res.end(JSON.stringify({ ok: true, sessions }));
82
99
  return;
83
100
  }
84
101
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -191,7 +208,7 @@ server.registerTool(
191
208
  server.registerTool(
192
209
  "lesa_check_inbox",
193
210
  {
194
- description: "Check for pending messages from the OpenClaw agent. The agent can push messages via the inbox HTTP endpoint (POST localhost:18790/message). Call this to see if the agent has sent anything. Returns all pending messages and clears the queue.",
211
+ 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.",
195
212
  inputSchema: {}
196
213
  },
197
214
  async () => {
@@ -199,13 +216,38 @@ server.registerTool(
199
216
  if (messages.length === 0) {
200
217
  return { content: [{ type: "text", text: "No pending messages." }] };
201
218
  }
202
- const text = messages.map((m) => `**${m.from}** (${m.timestamp}):
203
- ${m.message}`).join("\n\n---\n\n");
219
+ const text = messages.map((m) => `**${m.from}** [${m.type}] (${m.timestamp}):
220
+ ${m.body || m.message}`).join("\n\n---\n\n");
204
221
  return { content: [{ type: "text", text: `${messages.length} message(s):
205
222
 
206
223
  ${text}` }] };
207
224
  }
208
225
  );
226
+ server.registerTool(
227
+ "ldm_send_message",
228
+ {
229
+ description: "Send a message to any agent or session via the file-based inbox (~/.ldm/messages/). Works for agent-to-agent communication. For OpenClaw agents (like Lesa), use lesa_send_message instead (goes through the gateway). This tool writes directly to the shared inbox.\n\nTarget formats:\n 'cc-mini' ... default session\n 'cc-mini:brainstorm' ... named session\n 'cc-mini:*' ... broadcast to all sessions of that agent\n '*' ... broadcast to all agents",
230
+ inputSchema: {
231
+ to: z.string().describe("Target: 'agent', 'agent:session', 'agent:*', or '*'"),
232
+ message: z.string().describe("Message body"),
233
+ type: z.string().optional().default("chat").describe("Message type: chat, system, task (default: chat)")
234
+ }
235
+ },
236
+ async ({ to, message, type }) => {
237
+ const { agentId, sessionName } = getSessionIdentity();
238
+ const id = sendLdmMessage({
239
+ from: `${agentId}:${sessionName}`,
240
+ to,
241
+ body: message,
242
+ type
243
+ });
244
+ if (id) {
245
+ return { content: [{ type: "text", text: `Message sent (id: ${id}) to ${to}` }] };
246
+ } else {
247
+ return { content: [{ type: "text", text: "Failed to send message." }], isError: true };
248
+ }
249
+ }
250
+ );
209
251
  function registerSkillTools(skills) {
210
252
  const executableSkills = skills.filter((s) => s.hasScripts);
211
253
  const toolNameMap = /* @__PURE__ */ new Map();
@@ -267,6 +309,14 @@ ${lines.join("\n")}` }] };
267
309
  console.error(`wip-bridge: registered ${executableSkills.length} skill tools + oc_skills_list (${skills.length} total skills)`);
268
310
  }
269
311
  async function main() {
312
+ const agentId = process.env.LDM_AGENT_ID || "cc-mini";
313
+ const sessionName = process.env.LDM_SESSION_NAME || "default";
314
+ setSessionIdentity(agentId, sessionName);
315
+ console.error(`wip-bridge: session identity: ${agentId}:${sessionName}`);
316
+ const session = registerBridgeSession();
317
+ if (session) {
318
+ console.error(`wip-bridge: registered session ${agentId}--${sessionName} (pid ${session.pid})`);
319
+ }
270
320
  startInboxServer(config);
271
321
  try {
272
322
  const skills = discoverSkills(config.openclawDir);
package/lib/deploy.mjs CHANGED
@@ -12,7 +12,7 @@ import { execSync } from 'node:child_process';
12
12
  import {
13
13
  existsSync, readFileSync, writeFileSync, copyFileSync, cpSync, mkdirSync,
14
14
  lstatSync, readlinkSync, unlinkSync, chmodSync, readdirSync,
15
- renameSync, rmSync, statSync,
15
+ renameSync, rmSync, statSync, symlinkSync,
16
16
  } from 'node:fs';
17
17
  import { join, basename, resolve, dirname } from 'node:path';
18
18
  import { tmpdir } from 'node:os';
@@ -221,6 +221,46 @@ function findExistingInstalls(toolName, pkg, ocPluginConfig) {
221
221
  return matches;
222
222
  }
223
223
 
224
+ // ── Local dependency resolution ──
225
+ // Repos with file: dependencies (e.g. memory-crystal -> dream-weaver-protocol)
226
+ // fail to build in a clone context where the sibling directory doesn't exist.
227
+ // This function resolves those deps from the local LDM installation. No internet needed.
228
+
229
+ function resolveLocalDeps(repoPath) {
230
+ const pkg = readJSON(join(repoPath, 'package.json'));
231
+ if (!pkg) return;
232
+
233
+ const allDeps = { ...pkg.dependencies, ...pkg.optionalDependencies };
234
+ let resolved = 0;
235
+
236
+ for (const [name, spec] of Object.entries(allDeps)) {
237
+ if (typeof spec !== 'string' || !spec.startsWith('file:')) continue;
238
+
239
+ // This is a file: dependency that won't resolve in a clone
240
+ const extDir = join(LDM_EXTENSIONS, name);
241
+ if (existsSync(extDir)) {
242
+ const targetModules = join(repoPath, 'node_modules', name);
243
+ if (!existsSync(targetModules)) {
244
+ mkdirSync(join(repoPath, 'node_modules'), { recursive: true });
245
+ // Handle scoped packages (e.g. @scope/name)
246
+ const scopeDir = dirname(targetModules);
247
+ if (scopeDir !== join(repoPath, 'node_modules')) {
248
+ mkdirSync(scopeDir, { recursive: true });
249
+ }
250
+ symlinkSync(extDir, targetModules);
251
+ log(`Linked local dep: ${name} -> ${extDir}`);
252
+ resolved++;
253
+ }
254
+ } else {
255
+ log(`Dep ${name} not installed at ${extDir}, build may fail for this feature`);
256
+ }
257
+ }
258
+
259
+ if (resolved > 0) {
260
+ ok(`Resolved ${resolved} file: dep(s) from local LDM installation`);
261
+ }
262
+ }
263
+
224
264
  // ── Build step (fix #6) ──
225
265
 
226
266
  function runBuildIfNeeded(repoPath) {
@@ -228,13 +268,20 @@ function runBuildIfNeeded(repoPath) {
228
268
  if (!pkg) return true;
229
269
 
230
270
  const hasBuildScript = !!pkg.scripts?.build;
231
- const hasTsConfig = existsSync(join(repoPath, 'tsconfig.json'));
232
- const hasDist = existsSync(join(repoPath, 'dist'));
271
+ const distDir = join(repoPath, 'dist');
272
+ const hasPopulatedDist = existsSync(distDir) && readdirSync(distDir).length > 0;
233
273
 
234
- // Build if: has a build script AND (no dist/ or tsconfig exists implying TS)
235
- if (hasBuildScript && (!hasDist || hasTsConfig)) {
274
+ // Skip build if dist/ already has files (pre-built from npm or GitHub clone).
275
+ if (hasPopulatedDist) {
276
+ log(`Skipping build: dist/ already exists with ${readdirSync(distDir).length} files`);
277
+ } else if (hasBuildScript) {
236
278
  log(`Building ${pkg.name || basename(repoPath)}...`);
237
279
  try {
280
+ // Resolve file: deps from local LDM extensions before npm install.
281
+ // Without this, npm install fails for repos like memory-crystal that
282
+ // depend on file:../dream-weaver-protocol-private (sibling doesn't exist in clones).
283
+ resolveLocalDeps(repoPath);
284
+
238
285
  // Install deps first if node_modules is missing
239
286
  if (!existsSync(join(repoPath, 'node_modules'))) {
240
287
  execSync('npm install', { cwd: repoPath, stdio: 'pipe' });
@@ -1089,4 +1136,4 @@ export function disableExtension(name) {
1089
1136
 
1090
1137
  // ── Exports for ldm CLI ──
1091
1138
 
1092
- export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, CORE_EXTENSIONS };
1139
+ export { loadRegistry, saveRegistry, updateRegistry, readJSON, writeJSON, runBuildIfNeeded, resolveLocalDeps, CORE_EXTENSIONS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wipcomputer/wip-ldm-os",
3
- "version": "0.4.66",
3
+ "version": "0.4.68",
4
4
  "type": "module",
5
5
  "description": "LDM OS: identity, memory, and sovereignty infrastructure for AI agents",
6
6
  "engines": {
@@ -101,9 +101,9 @@ function getDefaultConfig() {
101
101
  maxTotalLines: 2000,
102
102
  steps: {
103
103
  sharedContext: { path: '~/.openclaw/workspace/SHARED-CONTEXT.md', label: 'SHARED-CONTEXT.md', stepNumber: 2, critical: true },
104
- journals: { dir: '~/Documents/wipcomputer--mac-mini-01/staff/Parker/Claude Code - Mini/documents/journals', label: 'Most Recent Journal (Parker)', stepNumber: 3, maxLines: 80, strategy: 'most-recent' },
104
+ journals: { dir: '~/.ldm/agents/cc-mini/memory/journals', label: 'Most Recent CC Journal (Legacy)', stepNumber: 3, maxLines: 80, strategy: 'most-recent' },
105
105
  workspaceDailyLogs: { dir: '~/.openclaw/workspace/memory', label: 'Workspace Daily Logs', stepNumber: 4, maxLines: 40, strategy: 'daily-logs', days: ['today', 'yesterday'] },
106
- fullHistory: { label: 'Full History', stepNumber: 5, reminder: 'Read on cold start: staff/Parker/Claude Code - Mini/documents/cc-full-history.md' },
106
+ fullHistory: { label: 'Full History', stepNumber: 5, reminder: 'Read on cold start: team/cc-mini/documents/cc-full-history.md' },
107
107
  context: { path: '~/.ldm/agents/cc-mini/CONTEXT.md', label: 'CC CONTEXT.md', stepNumber: 6, critical: true },
108
108
  soul: { path: '~/.ldm/agents/cc-mini/SOUL.md', label: 'CC SOUL.md', stepNumber: 7 },
109
109
  ccJournals: { dir: '~/.ldm/agents/cc-mini/memory/journals', label: 'Most Recent CC Journal', stepNumber: 8, maxLines: 80, strategy: 'most-recent' },
@@ -212,26 +212,53 @@ async function main() {
212
212
  }
213
213
  }
214
214
 
215
- // ── Register session (fire-and-forget) ──
215
+ // ── Register session (fire-and-forget, Phase 2) ──
216
216
  try {
217
217
  const { registerSession } = await import('../../lib/sessions.mjs');
218
- const name = process.env.CLAUDE_SESSION_NAME || basename(input?.cwd || process.cwd()) || `session-${process.pid}`;
218
+ const agentId = config?.agentId || 'unknown';
219
+ const sessionName = process.env.LDM_SESSION_NAME || process.env.CLAUDE_SESSION_NAME || basename(input?.cwd || process.cwd()) || 'default';
220
+ // Register with agent--session naming convention
219
221
  registerSession({
220
- name,
221
- agentId: config?.agentId || 'unknown',
222
+ name: `${agentId}--${sessionName}`,
223
+ agentId,
222
224
  pid: process.ppid || process.pid,
223
- meta: { cwd: input?.cwd },
225
+ meta: { cwd: input?.cwd, sessionName },
224
226
  });
225
227
  } catch {}
226
228
 
227
- // ── Check pending messages ──
229
+ // ── Check pending messages (Phase 3: boot hook delivery) ──
230
+ // Scans ~/.ldm/messages/ for messages addressed to this agent.
231
+ // Supports targeting: "cc-mini", "cc-mini:session", "cc-mini:*", "*", "all".
232
+ // Does NOT mark as read. The MCP check_inbox tool handles that.
228
233
  try {
229
234
  const { readMessages } = await import('../../lib/messages.mjs');
230
- const sessionName = process.env.CLAUDE_SESSION_NAME || basename(input?.cwd || process.cwd()) || 'unknown';
231
- const pending = readMessages(sessionName, { markRead: false });
232
- if (pending.length > 0) {
233
- const msgLines = pending.map(m => ` [${m.type}] ${m.from}: ${m.body}`).join('\n');
234
- sections.push(`== Pending Messages (${pending.length}) ==\n${msgLines}`);
235
+ const agentId = config?.agentId || 'cc-mini';
236
+ const sessionName = process.env.LDM_SESSION_NAME || process.env.CLAUDE_SESSION_NAME || basename(input?.cwd || process.cwd()) || 'default';
237
+
238
+ // Read messages using the existing lib/messages.mjs.
239
+ // It filters by exact sessionName or "all" broadcast.
240
+ // We also need to check for agent-level targeting (e.g. "cc-mini", "cc-mini:*").
241
+ const directMessages = readMessages(sessionName, { markRead: false });
242
+ const agentMessages = readMessages(agentId, { markRead: false });
243
+ const agentSessionMessages = readMessages(`${agentId}:${sessionName}`, { markRead: false });
244
+ const agentBroadcast = readMessages(`${agentId}:*`, { markRead: false });
245
+ const globalBroadcast = readMessages('*', { markRead: false });
246
+
247
+ // Deduplicate by message ID
248
+ const seen = new Set();
249
+ const allPending = [];
250
+ for (const msg of [...directMessages, ...agentMessages, ...agentSessionMessages, ...agentBroadcast, ...globalBroadcast]) {
251
+ if (msg.id && seen.has(msg.id)) continue;
252
+ if (msg.id) seen.add(msg.id);
253
+ allPending.push(msg);
254
+ }
255
+
256
+ // Sort by timestamp
257
+ allPending.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
258
+
259
+ if (allPending.length > 0) {
260
+ const msgLines = allPending.map(m => ` [${m.type || 'chat'}] from ${m.from}: ${m.body}`).join('\n');
261
+ sections.push(`== Pending Messages (${allPending.length}) ==\nYou have ${allPending.length} pending message(s). Use check_inbox to read and acknowledge them.\n${msgLines}`);
235
262
  }
236
263
  } catch {}
237
264
 
@@ -2,10 +2,11 @@
2
2
  // Handles messaging, memory search, and workspace access for OpenClaw agents.
3
3
 
4
4
  import { execSync, exec } from "node:child_process";
5
- import { readdirSync, readFileSync, existsSync, statSync } from "node:fs";
5
+ import { readdirSync, readFileSync, writeFileSync, existsSync, statSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
6
6
  import { join, relative, resolve } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { promisify } from "node:util";
9
+ import { randomUUID } from "node:crypto";
9
10
 
10
11
  const execAsync = promisify(exec);
11
12
 
@@ -31,9 +32,14 @@ export interface GatewayConfig {
31
32
  }
32
33
 
33
34
  export interface InboxMessage {
35
+ id: string;
36
+ type: string;
34
37
  from: string;
35
- message: string;
38
+ to: string;
39
+ body: string;
40
+ message?: string; // legacy compat: alias for body
36
41
  timestamp: string;
42
+ read: boolean;
37
43
  }
38
44
 
39
45
  export interface ConversationResult {
@@ -158,23 +164,285 @@ export function resolveGatewayConfig(openclawDir: string): GatewayConfig {
158
164
  return cachedGatewayConfig;
159
165
  }
160
166
 
161
- // ── Inbox ────────────────────────────────────────────────────────────
167
+ // ── Inbox (file-based via ~/.ldm/messages/) ─────────────────────────
168
+ //
169
+ // Phase 1: Replaces the in-memory queue with JSON files on disk.
170
+ // Phase 2: Adds session targeting (agent:session format).
171
+ // Phase 4: Cross-agent delivery to any agent via the same directory.
172
+ //
173
+ // Uses the existing lib/messages.mjs format. Each message is a JSON file
174
+ // at ~/.ldm/messages/{uuid}.json. Read means move to _processed/.
175
+
176
+ const MESSAGES_DIR = join(LDM_ROOT, "messages");
177
+ const PROCESSED_DIR = join(MESSAGES_DIR, "_processed");
178
+
179
+ // Session identity for this bridge process.
180
+ // Set via LDM_SESSION_NAME env or defaults to "default".
181
+ let _sessionAgentId = "cc-mini";
182
+ let _sessionName = process.env.LDM_SESSION_NAME || "default";
183
+
184
+ export function setSessionIdentity(agentId: string, sessionName: string): void {
185
+ _sessionAgentId = agentId;
186
+ _sessionName = sessionName;
187
+ }
162
188
 
163
- const inboxQueue: InboxMessage[] = [];
189
+ export function getSessionIdentity(): { agentId: string; sessionName: string } {
190
+ return { agentId: _sessionAgentId, sessionName: _sessionName };
191
+ }
164
192
 
165
- export function pushInbox(msg: InboxMessage): number {
166
- inboxQueue.push(msg);
167
- return inboxQueue.length;
193
+ /**
194
+ * Parse a "to" field into agent and session parts.
195
+ * Formats: "cc-mini" (default session), "cc-mini:brainstorm" (named),
196
+ * "cc-mini:*" (broadcast to all sessions of agent), "*" (all)
197
+ */
198
+ function parseTarget(to: string): { agent: string; session: string } {
199
+ if (to === "*") return { agent: "*", session: "*" };
200
+ const colonIdx = to.indexOf(":");
201
+ if (colonIdx === -1) return { agent: to, session: "default" };
202
+ return { agent: to.slice(0, colonIdx), session: to.slice(colonIdx + 1) };
168
203
  }
169
204
 
205
+ /**
206
+ * Check if a message's "to" field matches this session.
207
+ * Matches: exact agent + session, agent broadcast (agent:*),
208
+ * global broadcast (*), or agent with default session.
209
+ */
210
+ function messageMatchesSession(msgTo: string, agentId: string, sessionName: string): boolean {
211
+ // Global broadcast
212
+ if (msgTo === "*" || msgTo === "all") return true;
213
+
214
+ const target = parseTarget(msgTo);
215
+
216
+ // Different agent entirely
217
+ if (target.agent !== "*" && target.agent !== agentId) return false;
218
+
219
+ // Agent broadcast (agent:*)
220
+ if (target.session === "*") return true;
221
+
222
+ // Exact session match
223
+ return target.session === sessionName;
224
+ }
225
+
226
+ /**
227
+ * Write a message to the file-based inbox.
228
+ * Creates a JSON file at ~/.ldm/messages/{uuid}.json.
229
+ */
230
+ export function pushInbox(msg: { from: string; message?: string; body?: string; to?: string; type?: string }): number {
231
+ try {
232
+ mkdirSync(MESSAGES_DIR, { recursive: true });
233
+ const id = randomUUID();
234
+ const data: InboxMessage = {
235
+ id,
236
+ type: msg.type || "chat",
237
+ from: msg.from || "unknown",
238
+ to: msg.to || `${_sessionAgentId}:${_sessionName}`,
239
+ body: msg.body || msg.message || "",
240
+ timestamp: new Date().toISOString(),
241
+ read: false,
242
+ };
243
+ writeFileSync(join(MESSAGES_DIR, `${id}.json`), JSON.stringify(data, null, 2) + "\n");
244
+
245
+ // Return count of pending messages for this session
246
+ return inboxCount();
247
+ } catch {
248
+ return 0;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Read and drain all messages for this session from the inbox.
254
+ * Moves processed messages to ~/.ldm/messages/_processed/.
255
+ */
170
256
  export function drainInbox(): InboxMessage[] {
171
- const messages = [...inboxQueue];
172
- inboxQueue.length = 0;
173
- return messages;
257
+ try {
258
+ if (!existsSync(MESSAGES_DIR)) return [];
259
+
260
+ const files = readdirSync(MESSAGES_DIR).filter(f => f.endsWith(".json"));
261
+ const messages: InboxMessage[] = [];
262
+
263
+ for (const file of files) {
264
+ const filePath = join(MESSAGES_DIR, file);
265
+ try {
266
+ const data = JSON.parse(readFileSync(filePath, "utf-8")) as InboxMessage;
267
+
268
+ // Check if this message is addressed to us
269
+ if (!messageMatchesSession(data.to, _sessionAgentId, _sessionName)) continue;
270
+
271
+ // Normalize: ensure body is populated (legacy compat)
272
+ if (!data.body && data.message) data.body = data.message;
273
+
274
+ messages.push(data);
275
+
276
+ // Move to processed
277
+ try {
278
+ mkdirSync(PROCESSED_DIR, { recursive: true });
279
+ renameSync(filePath, join(PROCESSED_DIR, file));
280
+ } catch {
281
+ // If rename fails, try to delete
282
+ try { unlinkSync(filePath); } catch {}
283
+ }
284
+ } catch {
285
+ // Skip malformed files
286
+ }
287
+ }
288
+
289
+ // Sort by timestamp (oldest first)
290
+ messages.sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || ""));
291
+ return messages;
292
+ } catch {
293
+ return [];
294
+ }
174
295
  }
175
296
 
297
+ /**
298
+ * Count pending messages for this session without draining.
299
+ */
176
300
  export function inboxCount(): number {
177
- return inboxQueue.length;
301
+ try {
302
+ if (!existsSync(MESSAGES_DIR)) return 0;
303
+
304
+ const files = readdirSync(MESSAGES_DIR).filter(f => f.endsWith(".json"));
305
+ let count = 0;
306
+
307
+ for (const file of files) {
308
+ try {
309
+ const data = JSON.parse(readFileSync(join(MESSAGES_DIR, file), "utf-8"));
310
+ if (messageMatchesSession(data.to, _sessionAgentId, _sessionName)) count++;
311
+ } catch {
312
+ // Skip malformed
313
+ }
314
+ }
315
+
316
+ return count;
317
+ } catch {
318
+ return 0;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Get pending message counts broken down by session.
324
+ * Used by GET /status to show per-session counts.
325
+ */
326
+ export function inboxCountBySession(): Record<string, number> {
327
+ const counts: Record<string, number> = {};
328
+ try {
329
+ if (!existsSync(MESSAGES_DIR)) return counts;
330
+
331
+ const files = readdirSync(MESSAGES_DIR).filter(f => f.endsWith(".json"));
332
+ for (const file of files) {
333
+ try {
334
+ const data = JSON.parse(readFileSync(join(MESSAGES_DIR, file), "utf-8"));
335
+ const to = data.to || "unknown";
336
+ counts[to] = (counts[to] || 0) + 1;
337
+ } catch {}
338
+ }
339
+ } catch {}
340
+ return counts;
341
+ }
342
+
343
+ /**
344
+ * Send a message to another agent or session via the file-based inbox.
345
+ * Phase 4: Cross-agent messaging. Works for any agent, any session.
346
+ * This is the file-based path. For OpenClaw agents, use sendMessage() (gateway).
347
+ */
348
+ export function sendLdmMessage(opts: {
349
+ from?: string;
350
+ to: string;
351
+ body: string;
352
+ type?: string;
353
+ }): string | null {
354
+ try {
355
+ mkdirSync(MESSAGES_DIR, { recursive: true });
356
+ const id = randomUUID();
357
+ const data: InboxMessage = {
358
+ id,
359
+ type: opts.type || "chat",
360
+ from: opts.from || `${_sessionAgentId}:${_sessionName}`,
361
+ to: opts.to,
362
+ body: opts.body,
363
+ timestamp: new Date().toISOString(),
364
+ read: false,
365
+ };
366
+ writeFileSync(join(MESSAGES_DIR, `${id}.json`), JSON.stringify(data, null, 2) + "\n");
367
+ return id;
368
+ } catch {
369
+ return null;
370
+ }
371
+ }
372
+
373
+ // ── Session management (Phase 2) ────────────────────────────────────
374
+
375
+ const SESSIONS_DIR = join(LDM_ROOT, "sessions");
376
+
377
+ export interface SessionInfo {
378
+ name: string;
379
+ agentId: string;
380
+ pid: number;
381
+ startTime: string;
382
+ cwd: string;
383
+ alive: boolean;
384
+ meta?: Record<string, unknown>;
385
+ }
386
+
387
+ /**
388
+ * Register this bridge session in ~/.ldm/sessions/.
389
+ * Uses the agent--session naming convention.
390
+ */
391
+ export function registerBridgeSession(): SessionInfo | null {
392
+ try {
393
+ mkdirSync(SESSIONS_DIR, { recursive: true });
394
+ const fileName = `${_sessionAgentId}--${_sessionName}.json`;
395
+ const data: SessionInfo = {
396
+ name: _sessionName,
397
+ agentId: _sessionAgentId,
398
+ pid: process.pid,
399
+ startTime: new Date().toISOString(),
400
+ cwd: process.cwd(),
401
+ alive: true,
402
+ };
403
+ writeFileSync(join(SESSIONS_DIR, fileName), JSON.stringify(data, null, 2) + "\n");
404
+ return data;
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+
410
+ /**
411
+ * List active sessions. Validates PID liveness and cleans stale entries.
412
+ */
413
+ export function listActiveSessions(agentFilter?: string): SessionInfo[] {
414
+ try {
415
+ if (!existsSync(SESSIONS_DIR)) return [];
416
+
417
+ const files = readdirSync(SESSIONS_DIR).filter(f => f.endsWith(".json"));
418
+ const sessions: SessionInfo[] = [];
419
+
420
+ for (const file of files) {
421
+ try {
422
+ const filePath = join(SESSIONS_DIR, file);
423
+ const data = JSON.parse(readFileSync(filePath, "utf-8")) as SessionInfo;
424
+
425
+ // PID liveness check
426
+ let alive = false;
427
+ try {
428
+ process.kill(data.pid, 0);
429
+ alive = true;
430
+ } catch {
431
+ // Dead PID. Clean up.
432
+ try { unlinkSync(filePath); } catch {}
433
+ continue;
434
+ }
435
+
436
+ if (agentFilter && data.agentId !== agentFilter) continue;
437
+
438
+ sessions.push({ ...data, alive });
439
+ } catch {}
440
+ }
441
+
442
+ return sessions;
443
+ } catch {
444
+ return [];
445
+ }
178
446
  }
179
447
 
180
448
  // ── Send message to OpenClaw agent ───────────────────────────────────
@@ -6,14 +6,21 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
6
6
  import { createServer, IncomingMessage, ServerResponse } from "node:http";
7
7
  import { appendFileSync, mkdirSync } from "node:fs";
8
8
  import { join } from "node:path";
9
+ import { homedir } from "node:os";
9
10
  import { z } from "zod";
10
11
 
11
12
  import {
12
13
  resolveConfig,
13
14
  sendMessage,
15
+ sendLdmMessage,
14
16
  drainInbox,
15
17
  pushInbox,
16
18
  inboxCount,
19
+ inboxCountBySession,
20
+ setSessionIdentity,
21
+ getSessionIdentity,
22
+ registerBridgeSession,
23
+ listActiveSessions,
17
24
  searchConversations,
18
25
  searchWorkspace,
19
26
  readWorkspaceFile,
@@ -28,7 +35,7 @@ import {
28
35
 
29
36
  const config = resolveConfig();
30
37
 
31
- const METRICS_DIR = join(process.env.HOME || '/Users/lesa', '.openclaw', 'memory');
38
+ const METRICS_DIR = join(process.env.HOME || homedir(), '.openclaw', 'memory');
32
39
  const METRICS_PATH = join(METRICS_DIR, 'search-metrics.jsonl');
33
40
 
34
41
  function logSearchMetric(tool: string, query: string, resultCount: number) {
@@ -64,22 +71,27 @@ function startInboxServer(cfg: BridgeConfig): void {
64
71
  return;
65
72
  }
66
73
 
74
+ // POST /message: Write to file-based inbox (Phase 1 + 2 + 4)
75
+ // Accepts: { from, message|body, to?, type? }
76
+ // The "to" field supports: "cc-mini", "cc-mini:brainstorm", "cc-mini:*", "*"
67
77
  if (req.method === "POST" && req.url === "/message") {
68
78
  try {
69
79
  const body = JSON.parse(await readBody(req));
70
- const msg: InboxMessage = {
80
+ const { agentId, sessionName } = getSessionIdentity();
81
+ const queued = pushInbox({
71
82
  from: body.from || "agent",
72
- message: body.message,
73
- timestamp: new Date().toISOString(),
74
- };
75
- const queued = pushInbox(msg);
76
- console.error(`wip-bridge inbox: message from ${msg.from}`);
83
+ body: body.body || body.message || "",
84
+ to: body.to || `${agentId}:${sessionName}`,
85
+ type: body.type || "chat",
86
+ });
87
+ const messageBody = body.body || body.message || "";
88
+ console.error(`wip-bridge inbox: message from ${body.from || "agent"} to ${body.to || "default"}`);
77
89
 
78
90
  try {
79
91
  server.sendLoggingMessage({
80
92
  level: "info",
81
93
  logger: "wip-bridge",
82
- data: `[OpenClaw → Claude Code] ${msg.from}: ${msg.message}`,
94
+ data: `[inbox] ${body.from || "agent"}: ${messageBody}`,
83
95
  });
84
96
  } catch {}
85
97
 
@@ -92,9 +104,20 @@ function startInboxServer(cfg: BridgeConfig): void {
92
104
  return;
93
105
  }
94
106
 
107
+ // GET /status: Pending message counts (Phase 1 + 2)
95
108
  if (req.method === "GET" && req.url === "/status") {
109
+ const pending = inboxCount();
110
+ const bySession = inboxCountBySession();
96
111
  res.writeHead(200, { "Content-Type": "application/json" });
97
- res.end(JSON.stringify({ ok: true, pending: inboxCount() }));
112
+ res.end(JSON.stringify({ ok: true, pending, bySession }));
113
+ return;
114
+ }
115
+
116
+ // GET /sessions: List active sessions (Phase 2)
117
+ if (req.method === "GET" && req.url === "/sessions") {
118
+ const sessions = listActiveSessions();
119
+ res.writeHead(200, { "Content-Type": "application/json" });
120
+ res.end(JSON.stringify({ ok: true, sessions }));
98
121
  return;
99
122
  }
100
123
 
@@ -107,6 +130,8 @@ function startInboxServer(cfg: BridgeConfig): void {
107
130
  });
108
131
 
109
132
  httpServer.on("error", (err: Error) => {
133
+ // Port already bound by another bridge process. That's fine.
134
+ // This process reads from filesystem directly via check_inbox.
110
135
  console.error(`wip-bridge inbox server error: ${err.message}`);
111
136
  });
112
137
  }
@@ -240,14 +265,14 @@ server.registerTool(
240
265
  }
241
266
  );
242
267
 
243
- // Tool 5: Check inbox for messages from the OpenClaw agent
268
+ // Tool 5: Check inbox for messages (file-based, Phase 1)
244
269
  server.registerTool(
245
270
  "lesa_check_inbox",
246
271
  {
247
272
  description:
248
- "Check for pending messages from the OpenClaw agent. The agent can push messages " +
249
- "via the inbox HTTP endpoint (POST localhost:18790/message). Call this to see if " +
250
- "the agent has sent anything. Returns all pending messages and clears the queue.",
273
+ "Check for pending messages in the file-based inbox (~/.ldm/messages/). " +
274
+ "Messages can come from OpenClaw agents, other Claude Code sessions, or CLI. " +
275
+ "Returns all pending messages for this session and marks them as read.",
251
276
  inputSchema: {},
252
277
  },
253
278
  async () => {
@@ -258,13 +283,49 @@ server.registerTool(
258
283
  }
259
284
 
260
285
  const text = messages
261
- .map((m) => `**${m.from}** (${m.timestamp}):\n${m.message}`)
286
+ .map((m) => `**${m.from}** [${m.type}] (${m.timestamp}):\n${m.body || m.message}`)
262
287
  .join("\n\n---\n\n");
263
288
 
264
289
  return { content: [{ type: "text" as const, text: `${messages.length} message(s):\n\n${text}` }] };
265
290
  }
266
291
  );
267
292
 
293
+ // Tool 6: Send message to any agent via file-based inbox (Phase 4)
294
+ server.registerTool(
295
+ "ldm_send_message",
296
+ {
297
+ description:
298
+ "Send a message to any agent or session via the file-based inbox (~/.ldm/messages/). " +
299
+ "Works for agent-to-agent communication. For OpenClaw agents (like Lesa), use lesa_send_message " +
300
+ "instead (goes through the gateway). This tool writes directly to the shared inbox.\n\n" +
301
+ "Target formats:\n" +
302
+ " 'cc-mini' ... default session\n" +
303
+ " 'cc-mini:brainstorm' ... named session\n" +
304
+ " 'cc-mini:*' ... broadcast to all sessions of that agent\n" +
305
+ " '*' ... broadcast to all agents",
306
+ inputSchema: {
307
+ to: z.string().describe("Target: 'agent', 'agent:session', 'agent:*', or '*'"),
308
+ message: z.string().describe("Message body"),
309
+ type: z.string().optional().default("chat").describe("Message type: chat, system, task (default: chat)"),
310
+ },
311
+ },
312
+ async ({ to, message, type }) => {
313
+ const { agentId, sessionName } = getSessionIdentity();
314
+ const id = sendLdmMessage({
315
+ from: `${agentId}:${sessionName}`,
316
+ to,
317
+ body: message,
318
+ type,
319
+ });
320
+
321
+ if (id) {
322
+ return { content: [{ type: "text" as const, text: `Message sent (id: ${id}) to ${to}` }] };
323
+ } else {
324
+ return { content: [{ type: "text" as const, text: "Failed to send message." }], isError: true };
325
+ }
326
+ }
327
+ );
328
+
268
329
  // ── OpenClaw Skill Bridge ────────────────────────────────────────────
269
330
 
270
331
  function registerSkillTools(skills: SkillInfo[]): void {
@@ -350,6 +411,18 @@ function registerSkillTools(skills: SkillInfo[]): void {
350
411
  // ── Start ────────────────────────────────────────────────────────────
351
412
 
352
413
  async function main() {
414
+ // Phase 2: Set session identity from env or defaults
415
+ const agentId = process.env.LDM_AGENT_ID || "cc-mini";
416
+ const sessionName = process.env.LDM_SESSION_NAME || "default";
417
+ setSessionIdentity(agentId, sessionName);
418
+ console.error(`wip-bridge: session identity: ${agentId}:${sessionName}`);
419
+
420
+ // Phase 2: Register session in ~/.ldm/sessions/
421
+ const session = registerBridgeSession();
422
+ if (session) {
423
+ console.error(`wip-bridge: registered session ${agentId}--${sessionName} (pid ${session.pid})`);
424
+ }
425
+
353
426
  startInboxServer(config);
354
427
 
355
428
  // Discover and register OpenClaw skills