@wipcomputer/wip-ldm-os 0.4.73-alpha.8 → 0.4.74

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.
Files changed (61) hide show
  1. package/LICENSE +52 -0
  2. package/SKILL.md +8 -1
  3. package/bin/ldm.js +587 -82
  4. package/dist/bridge/chunk-3RG5ZIWI.js +10 -0
  5. package/dist/bridge/{chunk-LF7EMFBY.js → chunk-7NH6JBIO.js} +127 -49
  6. package/dist/bridge/cli.js +2 -1
  7. package/dist/bridge/core.d.ts +13 -1
  8. package/dist/bridge/core.js +4 -1
  9. package/dist/bridge/mcp-server.js +52 -7
  10. package/dist/bridge/openclaw.d.ts +5 -0
  11. package/dist/bridge/openclaw.js +11 -0
  12. package/docs/bridge/TECHNICAL.md +86 -0
  13. package/docs/doc-pipeline/README.md +74 -0
  14. package/docs/doc-pipeline/TECHNICAL.md +79 -0
  15. package/lib/deploy.mjs +175 -13
  16. package/lib/detect.mjs +20 -6
  17. package/package.json +2 -2
  18. package/shared/docs/README.md.tmpl +2 -2
  19. package/shared/docs/how-releases-work.md.tmpl +3 -1
  20. package/shared/docs/how-worktrees-work.md.tmpl +12 -7
  21. package/shared/rules/git-conventions.md +3 -3
  22. package/shared/rules/release-pipeline.md +1 -1
  23. package/shared/rules/security.md +1 -1
  24. package/shared/rules/workspace-boundaries.md +1 -1
  25. package/shared/rules/writing-style.md +1 -1
  26. package/shared/templates/claude-md-level1.md +7 -3
  27. package/src/bridge/core.ts +160 -56
  28. package/src/bridge/mcp-server.ts +93 -8
  29. package/src/bridge/openclaw.ts +14 -0
  30. package/src/hooks/inbox-check-hook.mjs +232 -0
  31. package/src/hooks/inbox-rewake-hook.mjs +388 -0
  32. package/src/hosted-mcp/.env.example +3 -0
  33. package/src/hosted-mcp/demo/agent.html +300 -0
  34. package/src/hosted-mcp/demo/agent.txt +84 -0
  35. package/src/hosted-mcp/demo/fallback.jpg +0 -0
  36. package/src/hosted-mcp/demo/footer.js +74 -0
  37. package/src/hosted-mcp/demo/index.html +1303 -0
  38. package/src/hosted-mcp/demo/login.html +548 -0
  39. package/src/hosted-mcp/demo/privacy.html +223 -0
  40. package/src/hosted-mcp/demo/sprites.jpg +0 -0
  41. package/src/hosted-mcp/demo/sprites.png +0 -0
  42. package/src/hosted-mcp/demo/tos.html +198 -0
  43. package/src/hosted-mcp/deploy.sh +70 -0
  44. package/src/hosted-mcp/ecosystem.config.cjs +14 -0
  45. package/src/hosted-mcp/inbox.mjs +64 -0
  46. package/src/hosted-mcp/legal/internet-services/terms/site.html +205 -0
  47. package/src/hosted-mcp/legal/privacy/en-ww/index.html +230 -0
  48. package/src/hosted-mcp/nginx/mcp-oauth.conf +98 -0
  49. package/src/hosted-mcp/nginx/mcp-server.conf +17 -0
  50. package/src/hosted-mcp/nginx/wip.computer.conf +45 -0
  51. package/src/hosted-mcp/package-lock.json +2092 -0
  52. package/src/hosted-mcp/package.json +23 -0
  53. package/src/hosted-mcp/prisma/migrations/20260406233014_init/migration.sql +68 -0
  54. package/src/hosted-mcp/prisma/migrations/migration_lock.toml +3 -0
  55. package/src/hosted-mcp/prisma/schema.prisma +57 -0
  56. package/src/hosted-mcp/prisma.config.ts +14 -0
  57. package/src/hosted-mcp/server.mjs +2093 -0
  58. package/src/hosted-mcp/shared/kaleidoscope.css +139 -0
  59. package/src/hosted-mcp/shared/kaleidoscope.js +192 -0
  60. package/src/hosted-mcp/tools.mjs +73 -0
  61. package/templates/hooks/pre-commit +5 -0
@@ -10,6 +10,31 @@ import { randomUUID } from "node:crypto";
10
10
 
11
11
  const execAsync = promisify(exec);
12
12
 
13
+ // ── Settings ─────────────────────────────────────────────────────────
14
+ // All tunable constants in one place. No magic numbers below this block.
15
+
16
+ const GATEWAY_HOST = "127.0.0.1";
17
+ const DEFAULT_GATEWAY_PORT = 18_789; // openclaw.json gateway.port fallback
18
+ const DEFAULT_INBOX_PORT = 18_790; // env LESA_BRIDGE_INBOX_PORT fallback
19
+ const GATEWAY_TIMEOUT_MS = 120_000; // max wait for gateway chat response (2 min, agent turns can be long)
20
+ const OP_CLI_TIMEOUT_MS = 10_000; // max wait for 1Password CLI
21
+ const EMBEDDING_API_URL = "https://api.openai.com/v1/embeddings";
22
+ const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
23
+ const DEFAULT_EMBEDDING_DIMS = 1_536;
24
+ const VECTOR_SEARCH_ROW_LIMIT = 1_000; // max rows scanned for cosine ranking
25
+ const RECENCY_DECAY_RATE = 0.01; // per-day decay multiplier
26
+ const RECENCY_FLOOR = 0.5; // minimum recency weight
27
+ const FRESHNESS_FRESH_DAYS = 3;
28
+ const FRESHNESS_RECENT_DAYS = 7;
29
+ const FRESHNESS_AGING_DAYS = 14;
30
+ const DEFAULT_SEARCH_LIMIT = 5; // default results for searchConversations
31
+ const WORKSPACE_MAX_DEPTH = 4; // findMarkdownFiles recursion limit
32
+ const WORKSPACE_MAX_EXCERPTS = 5; // max excerpts per file in search
33
+ const WORKSPACE_MAX_RESULTS = 10; // max files returned from workspace search
34
+ const SKILL_EXEC_TIMEOUT_MS = 120_000; // max wait for skill script execution
35
+ const SKILL_EXEC_MAX_BUFFER = 10 * 1024 * 1024; // 10 MB stdout/stderr cap
36
+ const MS_PER_DAY = 1_000 * 60 * 60 * 24;
37
+
13
38
  // ── Constants ─────────────────────────────────────────────────────────
14
39
 
15
40
  const HOME = process.env.HOME || homedir();
@@ -66,9 +91,9 @@ export function resolveConfig(overrides?: Partial<BridgeConfig>): BridgeConfig {
66
91
  openclawDir,
67
92
  workspaceDir: overrides?.workspaceDir || join(openclawDir, "workspace"),
68
93
  dbPath: overrides?.dbPath || join(openclawDir, "memory", "context-embeddings.sqlite"),
69
- inboxPort: overrides?.inboxPort || parseInt(process.env.LESA_BRIDGE_INBOX_PORT || "18790", 10),
70
- embeddingModel: overrides?.embeddingModel || "text-embedding-3-small",
71
- embeddingDimensions: overrides?.embeddingDimensions || 1536,
94
+ inboxPort: overrides?.inboxPort || parseInt(process.env.LESA_BRIDGE_INBOX_PORT || String(DEFAULT_INBOX_PORT), 10),
95
+ embeddingModel: overrides?.embeddingModel || DEFAULT_EMBEDDING_MODEL,
96
+ embeddingDimensions: overrides?.embeddingDimensions || DEFAULT_EMBEDDING_DIMS,
72
97
  };
73
98
  }
74
99
 
@@ -88,9 +113,9 @@ export function resolveConfigMulti(overrides?: Partial<BridgeConfig>): BridgeCon
88
113
  openclawDir,
89
114
  workspaceDir: raw.workspaceDir || overrides?.workspaceDir || join(openclawDir, "workspace"),
90
115
  dbPath: raw.dbPath || overrides?.dbPath || join(openclawDir, "memory", "context-embeddings.sqlite"),
91
- inboxPort: raw.inboxPort || overrides?.inboxPort || parseInt(process.env.LESA_BRIDGE_INBOX_PORT || "18790", 10),
92
- embeddingModel: raw.embeddingModel || overrides?.embeddingModel || "text-embedding-3-small",
93
- embeddingDimensions: raw.embeddingDimensions || overrides?.embeddingDimensions || 1536,
116
+ inboxPort: raw.inboxPort || overrides?.inboxPort || parseInt(process.env.LESA_BRIDGE_INBOX_PORT || String(DEFAULT_INBOX_PORT), 10),
117
+ embeddingModel: raw.embeddingModel || overrides?.embeddingModel || DEFAULT_EMBEDDING_MODEL,
118
+ embeddingDimensions: raw.embeddingDimensions || overrides?.embeddingDimensions || DEFAULT_EMBEDDING_DIMS,
94
119
  };
95
120
  } catch {
96
121
  // LDM config unreadable, fall through to legacy
@@ -123,7 +148,7 @@ export function resolveApiKey(openclawDir: string): string | null {
123
148
  `op read "op://Agent Secrets/OpenAI API/api key" 2>/dev/null`,
124
149
  {
125
150
  env: { ...process.env, OP_SERVICE_ACCOUNT_TOKEN: saToken },
126
- timeout: 10000,
151
+ timeout: OP_CLI_TIMEOUT_MS,
127
152
  encoding: "utf-8",
128
153
  }
129
154
  ).trim();
@@ -154,7 +179,7 @@ export function resolveGatewayConfig(openclawDir: string): GatewayConfig {
154
179
 
155
180
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
156
181
  const token = config?.gateway?.auth?.token;
157
- const port = config?.gateway?.port || 18789;
182
+ const port = config?.gateway?.port || DEFAULT_GATEWAY_PORT;
158
183
 
159
184
  if (!token) {
160
185
  throw new Error("No gateway.auth.token found in openclaw.json");
@@ -190,6 +215,41 @@ export function getSessionIdentity(): { agentId: string; sessionName: string } {
190
215
  return { agentId: _sessionAgentId, sessionName: _sessionName };
191
216
  }
192
217
 
218
+ /**
219
+ * Re-read the session name from CC's session metadata file.
220
+ *
221
+ * CC writes the /rename label to ~/.claude/sessions/<pid>.json. The bridge
222
+ * reads this once on boot, but the name can change at any time via /rename
223
+ * or /resume. Calling this before each inbox check ensures the bridge
224
+ * always uses the current label for message targeting.
225
+ *
226
+ * Cheap: one file read per call. No network. No delay.
227
+ */
228
+ export function refreshSessionIdentity(): void {
229
+ try {
230
+ const sessionPath = join(
231
+ process.env.HOME || require("node:os").homedir(),
232
+ ".claude",
233
+ "sessions",
234
+ `${process.ppid}.json`
235
+ );
236
+ const data = JSON.parse(readFileSync(sessionPath, "utf-8"));
237
+ if (data.name && typeof data.name === "string" && data.name !== _sessionName) {
238
+ const oldName = _sessionName;
239
+ _sessionName = data.name;
240
+ // Re-register with the new name so other agents can find us
241
+ try {
242
+ registerBridgeSession();
243
+ } catch {}
244
+ if (oldName !== _sessionName) {
245
+ process.stderr.write(`wip-bridge: session name updated: ${oldName} -> ${_sessionName}\n`);
246
+ }
247
+ }
248
+ } catch {
249
+ // File doesn't exist or can't be read. Keep current name.
250
+ }
251
+ }
252
+
193
253
  /**
194
254
  * Parse a "to" field into agent and session parts.
195
255
  * Formats: "cc-mini" (default session), "cc-mini:brainstorm" (named),
@@ -198,14 +258,18 @@ export function getSessionIdentity(): { agentId: string; sessionName: string } {
198
258
  function parseTarget(to: string): { agent: string; session: string } {
199
259
  if (to === "*") return { agent: "*", session: "*" };
200
260
  const colonIdx = to.indexOf(":");
201
- if (colonIdx === -1) return { agent: to, session: "default" };
261
+ // Agent-only address (no colon, e.g. "cc-mini") is a broadcast to all
262
+ // sessions of that agent. Previously this defaulted to session "default"
263
+ // which silently dropped messages for any session with a non-default name.
264
+ // See: ai/product/bugs/bridge/2026-04-10--cc-mini--bridge-reply-addressing-mismatch.md
265
+ if (colonIdx === -1) return { agent: to, session: "*" };
202
266
  return { agent: to.slice(0, colonIdx), session: to.slice(colonIdx + 1) };
203
267
  }
204
268
 
205
269
  /**
206
270
  * Check if a message's "to" field matches this session.
207
271
  * Matches: exact agent + session, agent broadcast (agent:*),
208
- * global broadcast (*), or agent with default session.
272
+ * global broadcast (*), or agent-only address (no session qualifier).
209
273
  */
210
274
  function messageMatchesSession(msgTo: string, agentId: string, sessionName: string): boolean {
211
275
  // Global broadcast
@@ -216,7 +280,7 @@ function messageMatchesSession(msgTo: string, agentId: string, sessionName: stri
216
280
  // Different agent entirely
217
281
  if (target.agent !== "*" && target.agent !== agentId) return false;
218
282
 
219
- // Agent broadcast (agent:*)
283
+ // Agent broadcast (agent:*) or agent-only address
220
284
  if (target.session === "*") return true;
221
285
 
222
286
  // Exact session match
@@ -254,6 +318,10 @@ export function pushInbox(msg: { from: string; message?: string; body?: string;
254
318
  * Moves processed messages to ~/.ldm/messages/_processed/.
255
319
  */
256
320
  export function drainInbox(): InboxMessage[] {
321
+ // Re-read session name from CC metadata before filtering.
322
+ // Handles /rename and /resume happening after bridge boot.
323
+ refreshSessionIdentity();
324
+
257
325
  try {
258
326
  if (!existsSync(MESSAGES_DIR)) return [];
259
327
 
@@ -298,6 +366,7 @@ export function drainInbox(): InboxMessage[] {
298
366
  * Count pending messages for this session without draining.
299
367
  */
300
368
  export function inboxCount(): number {
369
+ refreshSessionIdentity();
301
370
  try {
302
371
  if (!existsSync(MESSAGES_DIR)) return 0;
303
372
 
@@ -450,49 +519,84 @@ export function listActiveSessions(agentFilter?: string): SessionInfo[] {
450
519
  export async function sendMessage(
451
520
  openclawDir: string,
452
521
  message: string,
453
- options?: { agentId?: string; user?: string; senderLabel?: string }
522
+ options?: { agentId?: string; user?: string; senderLabel?: string; fireAndForget?: boolean }
454
523
  ): Promise<string> {
455
524
  const { token, port } = resolveGatewayConfig(openclawDir);
456
525
  const agentId = options?.agentId || "main";
457
526
  const senderLabel = options?.senderLabel || "Claude Code";
527
+ const fireAndForget = options?.fireAndForget ?? false;
458
528
 
459
529
  // Send user: "main" to route to the main session (agent:main:main).
460
530
  // This ensures Parker sees CC's messages in the same stream as iMessage.
461
531
  // The OpenClaw gateway treats user: "main" as "use the default session."
462
- const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
463
- method: "POST",
464
- headers: {
465
- Authorization: `Bearer ${token}`,
466
- "Content-Type": "application/json",
467
- "x-openclaw-scopes": "operator.read,operator.write",
468
- "x-openclaw-session-key": `agent:${agentId}:main`,
469
- },
470
- body: JSON.stringify({
471
- model: `openclaw/${agentId}`,
472
- messages: [
473
- {
474
- role: "user",
475
- content: `[${senderLabel}]: ${message}`,
476
- },
477
- ],
478
- }),
532
+ const requestBody = JSON.stringify({
533
+ model: `openclaw/${agentId}`,
534
+ messages: [
535
+ {
536
+ role: "user",
537
+ content: `[${senderLabel}]: ${message}`,
538
+ },
539
+ ],
479
540
  });
480
541
 
481
- if (!response.ok) {
482
- const body = await response.text();
483
- throw new Error(`Gateway returned ${response.status}: ${body}`);
484
- }
485
-
486
- const data = (await response.json()) as {
487
- choices: Array<{ message: { content: string } }>;
542
+ const requestHeaders: Record<string, string> = {
543
+ Authorization: `Bearer ${token}`,
544
+ "Content-Type": "application/json",
545
+ "x-openclaw-scopes": "operator.read,operator.write",
546
+ "x-openclaw-session-key": `agent:${agentId}:main`,
488
547
  };
489
548
 
490
- const reply = data.choices?.[0]?.message?.content;
491
- if (!reply) {
492
- throw new Error("No response content from gateway");
549
+ const url = `http://${GATEWAY_HOST}:${port}/v1/chat/completions`;
550
+
551
+ // Fire-and-forget: send the request and return immediately.
552
+ // The message is queued in the gateway. Lēsa processes it when she's ready.
553
+ // No timeout. No waiting. Like dropping a letter in a mailbox.
554
+ if (fireAndForget) {
555
+ fetch(url, {
556
+ method: "POST",
557
+ headers: requestHeaders,
558
+ body: requestBody,
559
+ }).catch(() => {}); // Ignore errors silently
560
+ return "Message sent (queued). Response will arrive in the TUI.";
493
561
  }
494
562
 
495
- return reply;
563
+ // Synchronous: wait for the full response.
564
+ const controller = new AbortController();
565
+ const timeoutId = setTimeout(() => controller.abort(), GATEWAY_TIMEOUT_MS);
566
+
567
+ try {
568
+ const response = await fetch(url, {
569
+ method: "POST",
570
+ headers: requestHeaders,
571
+ body: requestBody,
572
+ signal: controller.signal,
573
+ });
574
+ clearTimeout(timeoutId);
575
+
576
+ if (!response.ok) {
577
+ const body = await response.text();
578
+ throw new Error(`Gateway returned ${response.status}: ${body}`);
579
+ }
580
+
581
+ const data = (await response.json()) as {
582
+ choices: Array<{ message: { content: string } }>;
583
+ };
584
+
585
+ const reply = data.choices?.[0]?.message?.content;
586
+ if (!reply) {
587
+ throw new Error("No response content from gateway");
588
+ }
589
+
590
+ return reply;
591
+ } catch (err: any) {
592
+ clearTimeout(timeoutId);
593
+ if (err.name === "AbortError") {
594
+ throw new Error(
595
+ "Gateway timeout: Lesa may be busy or the gateway is processing another request. Try again in a moment."
596
+ );
597
+ }
598
+ throw err;
599
+ }
496
600
  }
497
601
 
498
602
  // ── Embedding helpers ────────────────────────────────────────────────
@@ -500,10 +604,10 @@ export async function sendMessage(
500
604
  export async function getQueryEmbedding(
501
605
  text: string,
502
606
  apiKey: string,
503
- model = "text-embedding-3-small",
504
- dimensions = 1536
607
+ model = DEFAULT_EMBEDDING_MODEL,
608
+ dimensions = DEFAULT_EMBEDDING_DIMS
505
609
  ): Promise<number[]> {
506
- const response = await fetch("https://api.openai.com/v1/embeddings", {
610
+ const response = await fetch(EMBEDDING_API_URL, {
507
611
  method: "POST",
508
612
  headers: {
509
613
  Authorization: `Bearer ${apiKey}`,
@@ -549,15 +653,15 @@ export function cosineSimilarity(a: number[], b: number[]): number {
549
653
  // ── Recency scoring ─────────────────────────────────────────────────
550
654
 
551
655
  function recencyWeight(ageDays: number): number {
552
- // Linear decay with floor at 0.5. Old stuff never fully disappears
656
+ // Linear decay with floor. Old stuff never fully disappears
553
657
  // but fresh context wins ties. ~50 days to hit the floor.
554
- return Math.max(0.5, 1.0 - ageDays * 0.01);
658
+ return Math.max(RECENCY_FLOOR, 1.0 - ageDays * RECENCY_DECAY_RATE);
555
659
  }
556
660
 
557
661
  function freshnessLabel(ageDays: number): "fresh" | "recent" | "aging" | "stale" {
558
- if (ageDays < 3) return "fresh";
559
- if (ageDays < 7) return "recent";
560
- if (ageDays < 14) return "aging";
662
+ if (ageDays < FRESHNESS_FRESH_DAYS) return "fresh";
663
+ if (ageDays < FRESHNESS_RECENT_DAYS) return "recent";
664
+ if (ageDays < FRESHNESS_AGING_DAYS) return "aging";
561
665
  return "stale";
562
666
  }
563
667
 
@@ -566,7 +670,7 @@ function freshnessLabel(ageDays: number): "fresh" | "recent" | "aging" | "stale"
566
670
  export async function searchConversations(
567
671
  config: BridgeConfig,
568
672
  query: string,
569
- limit = 5
673
+ limit = DEFAULT_SEARCH_LIMIT
570
674
  ): Promise<ConversationResult[]> {
571
675
  // Lazy import to avoid requiring better-sqlite3 if not needed
572
676
  const Database = (await import("better-sqlite3")).default;
@@ -593,7 +697,7 @@ export async function searchConversations(
593
697
  FROM conversation_chunks
594
698
  WHERE embedding IS NOT NULL
595
699
  ORDER BY timestamp DESC
596
- LIMIT 1000`
700
+ LIMIT ${VECTOR_SEARCH_ROW_LIMIT}`
597
701
  )
598
702
  .all() as Array<{
599
703
  chunk_text: string;
@@ -607,7 +711,7 @@ export async function searchConversations(
607
711
  return rows
608
712
  .map((row) => {
609
713
  const cosine = cosineSimilarity(queryEmbedding, blobToEmbedding(row.embedding));
610
- const ageDays = (now - row.timestamp) / (1000 * 60 * 60 * 24);
714
+ const ageDays = (now - row.timestamp) / MS_PER_DAY;
611
715
  const weight = recencyWeight(ageDays);
612
716
  return {
613
717
  text: row.chunk_text,
@@ -652,7 +756,7 @@ export async function searchConversations(
652
756
 
653
757
  // ── Workspace search ─────────────────────────────────────────────────
654
758
 
655
- export function findMarkdownFiles(dir: string, maxDepth = 4, depth = 0): string[] {
759
+ export function findMarkdownFiles(dir: string, maxDepth = WORKSPACE_MAX_DEPTH, depth = 0): string[] {
656
760
  if (depth > maxDepth || !existsSync(dir)) return [];
657
761
 
658
762
  const files: string[] = [];
@@ -687,7 +791,7 @@ export function searchWorkspace(workspaceDir: string, query: string): WorkspaceS
687
791
 
688
792
  const lines = content.split("\n");
689
793
  const excerpts: string[] = [];
690
- for (let i = 0; i < lines.length && excerpts.length < 5; i++) {
794
+ for (let i = 0; i < lines.length && excerpts.length < WORKSPACE_MAX_EXCERPTS; i++) {
691
795
  const lineLower = lines[i].toLowerCase();
692
796
  if (words.some((w) => lineLower.includes(w))) {
693
797
  const start = Math.max(0, i - 1);
@@ -702,7 +806,7 @@ export function searchWorkspace(workspaceDir: string, query: string): WorkspaceS
702
806
  }
703
807
  }
704
808
 
705
- return results.sort((a, b) => b.score - a.score).slice(0, 10);
809
+ return results.sort((a, b) => b.score - a.score).slice(0, WORKSPACE_MAX_RESULTS);
706
810
  }
707
811
 
708
812
  // ── Read workspace file ──────────────────────────────────────────────
@@ -858,8 +962,8 @@ export async function executeSkillScript(
858
962
  `${interpreter} "${scriptPath}" ${args}`,
859
963
  {
860
964
  env: { ...process.env },
861
- timeout: 120000,
862
- maxBuffer: 10 * 1024 * 1024, // 10MB
965
+ timeout: SKILL_EXEC_TIMEOUT_MS,
966
+ maxBuffer: SKILL_EXEC_MAX_BUFFER,
863
967
  }
864
968
  );
865
969
  return stdout || stderr || "(no output)";
@@ -4,7 +4,7 @@
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { createServer, IncomingMessage, ServerResponse } from "node:http";
7
- import { appendFileSync, mkdirSync } from "node:fs";
7
+ import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { z } from "zod";
@@ -242,7 +242,18 @@ server.registerTool(
242
242
  }
243
243
  );
244
244
 
245
- // Tool 4: Send a message to the OpenClaw agent
245
+ // Tool 4: Send a message to the OpenClaw agent (async, non-blocking)
246
+ //
247
+ // Sends via fire-and-forget to the gateway so CC is not blocked waiting for
248
+ // the reply. The message hits Lēsa's full pipeline (visible in Parker's TUI).
249
+ // Lēsa's reply arrives in the file inbox (~/.ldm/messages/) which CC picks up
250
+ // via the UserPromptSubmit hook or check_inbox tool.
251
+ //
252
+ // Also writes the outbound message to the file inbox as a "sent" record so
253
+ // there's a complete file trail of both sides of the conversation.
254
+ //
255
+ // Changed 2026-04-06: was synchronous (blocked up to 120s). Now async.
256
+ // See ai/product/bugs/bridge/2026-04-06--cc-mini--bridge-async-inbox-plan.md
246
257
  server.registerTool(
247
258
  "lesa_send_message",
248
259
  {
@@ -250,15 +261,35 @@ server.registerTool(
250
261
  "Send a message to the OpenClaw agent through the gateway. Routes through the agent's " +
251
262
  "full pipeline: memory, tools, personality, workspace. Use this for direct communication: " +
252
263
  "asking questions, sharing findings, coordinating work, or having a discussion. " +
253
- "Messages are prefixed with [Claude Code] so the agent knows the source.",
264
+ "Messages are prefixed with [Claude Code] so the agent knows the source.\n\n" +
265
+ "This is async: returns immediately after sending. The agent's reply will arrive in " +
266
+ "your inbox (check via lesa_check_inbox or it appears automatically on your next turn).",
254
267
  inputSchema: {
255
268
  message: z.string().describe("Message to send to the OpenClaw agent"),
256
269
  },
257
270
  },
258
271
  async ({ message }) => {
259
272
  try {
260
- const reply = await sendMessage(config.openclawDir, message);
261
- return { content: [{ type: "text" as const, text: reply }] };
273
+ // 1. Fire-and-forget to gateway (Lēsa sees it in TUI, Parker sees it)
274
+ await sendMessage(config.openclawDir, message, { fireAndForget: true });
275
+
276
+ // 2. Write outbound record to file inbox so the conversation trail is complete
277
+ const { agentId, sessionName } = getSessionIdentity();
278
+ sendLdmMessage({
279
+ from: `${agentId}:${sessionName}`,
280
+ to: "lesa",
281
+ body: message,
282
+ type: "chat",
283
+ });
284
+
285
+ return {
286
+ content: [{
287
+ type: "text" as const,
288
+ text: `Sent to Lēsa: "${message}"\n\nMessage delivered to the gateway (fire-and-forget). ` +
289
+ `Lēsa will process it through her full pipeline. Her reply will arrive in your inbox. ` +
290
+ `Use lesa_check_inbox to check, or it will appear automatically on your next turn.`,
291
+ }],
292
+ };
262
293
  } catch (err: any) {
263
294
  return { content: [{ type: "text" as const, text: `Error sending message: ${err.message}` }], isError: true };
264
295
  }
@@ -410,12 +441,66 @@ function registerSkillTools(skills: SkillInfo[]): void {
410
441
 
411
442
  // ── Start ────────────────────────────────────────────────────────────
412
443
 
444
+ /**
445
+ * Resolve session name from Claude Code's session metadata.
446
+ *
447
+ * CC writes session files to ~/.claude/sessions/<pid>.json with the
448
+ * /rename label as the "name" field. The bridge MCP server is a child
449
+ * process of CC, so process.ppid gives the CC PID. Reading the parent's
450
+ * session file gives us the label automatically, no env var needed.
451
+ *
452
+ * Fallback chain: CC session file -> LDM_SESSION_NAME env -> "default"
453
+ */
454
+ function resolveSessionName(): string {
455
+ // 1. Try CC session file for parent PID.
456
+ // CC and the bridge MCP server start concurrently. CC writes the session
457
+ // file after boot, but the bridge may read it before it exists or before
458
+ // the /rename label is written. Retry with a brief delay to handle the
459
+ // race. Three attempts, 500ms apart = up to 1s total wait. If it still
460
+ // fails, fall through to env var or default.
461
+ const ccSessionDir = join(process.env.HOME || homedir(), ".claude", "sessions");
462
+ const ccSessionPath = join(ccSessionDir, `${process.ppid}.json`);
463
+
464
+ for (let attempt = 0; attempt < 3; attempt++) {
465
+ try {
466
+ const data = JSON.parse(readFileSync(ccSessionPath, "utf-8"));
467
+ if (data.name && typeof data.name === "string") {
468
+ return data.name;
469
+ }
470
+ // File exists but no name yet. CC hasn't written /rename label.
471
+ // On the last attempt, break to fallback. Otherwise wait and retry.
472
+ if (attempt < 2) {
473
+ const { execSync } = require("node:child_process");
474
+ execSync("sleep 0.5", { stdio: "ignore" });
475
+ }
476
+ } catch {
477
+ // File doesn't exist yet. Wait and retry.
478
+ if (attempt < 2) {
479
+ try {
480
+ const { execSync } = require("node:child_process");
481
+ execSync("sleep 0.5", { stdio: "ignore" });
482
+ } catch {}
483
+ }
484
+ }
485
+ }
486
+
487
+ // 2. Try env var (explicit override)
488
+ if (process.env.LDM_SESSION_NAME) {
489
+ return process.env.LDM_SESSION_NAME;
490
+ }
491
+
492
+ // 3. Default
493
+ return "default";
494
+ }
495
+
413
496
  async function main() {
414
- // Phase 2: Set session identity from env or defaults
497
+ // Set session identity: auto-detect from CC session metadata, env, or default
415
498
  const agentId = process.env.LDM_AGENT_ID || "cc-mini";
416
- const sessionName = process.env.LDM_SESSION_NAME || "default";
499
+ const sessionName = resolveSessionName();
417
500
  setSessionIdentity(agentId, sessionName);
418
- console.error(`wip-bridge: session identity: ${agentId}:${sessionName}`);
501
+ console.error(`wip-bridge: session identity: ${agentId}:${sessionName} (resolved from ${
502
+ sessionName !== "default" ? "CC session file or env" : "default"
503
+ })`);
419
504
 
420
505
  // Phase 2: Register session in ~/.ldm/sessions/
421
506
  const session = registerBridgeSession();
@@ -0,0 +1,14 @@
1
+ // openclaw.ts ... Bridge plugin entry point.
2
+ // Skill-only plugin: all functionality lives in skills/ and the MCP server
3
+ // (mcp-server.js) which is launched out-of-process. No tools, CLI, or HTTP
4
+ // routes are registered with the OpenClaw runtime.
5
+ //
6
+ // This file exists only so OpenClaw's plugin loader can discover the plugin
7
+ // when package.json declares `openclaw.extensions: ["./dist/openclaw.js"]`.
8
+ // Without it, gateway startup logs a "plugin not found" warning.
9
+
10
+ export default {
11
+ register(api: any) {
12
+ api.logger.info('lesa-bridge plugin registered (skill-only; MCP server runs out of process)');
13
+ },
14
+ };