agent-bridge-mcp 1.1.0 → 1.4.0

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 (3) hide show
  1. package/README.md +141 -0
  2. package/package.json +1 -1
  3. package/server.mjs +537 -43
package/README.md CHANGED
@@ -88,6 +88,8 @@ Data directory:
88
88
  1740524430000-x7k2f9.json # { from, to, content, timestamp }
89
89
  context/
90
90
  nova-main.json # { agentName, agentId, project, updatedAt, content }
91
+ cursors/
92
+ nova-main.cursor # timestamp of last delivered message
91
93
  ```
92
94
 
93
95
  ## Example Usage
@@ -154,6 +156,145 @@ Pass env vars through your MCP config:
154
156
  }
155
157
  ```
156
158
 
159
+ ## Automatic Context Sharing (Stop Hook)
160
+
161
+ The `--ingest` flag lets you auto-capture every conversation exchange into the context directory — no manual `share_context` calls needed.
162
+
163
+ **How it works:** Claude Code fires a [Stop hook](https://docs.anthropic.com/en/docs/claude-code/hooks) after every agent response. The hook pipes `session_id` and `last_assistant_message` via stdin. `agent-bridge-mcp --ingest` reads this, pairs it with the user prompt (saved by a `UserPromptSubmit` hook), strips system tags, and appends the exchange to the agent's context file.
164
+
165
+ **Setup:**
166
+
167
+ 1. Add a `UserPromptSubmit` hook to save user prompts (required for `--ingest` to capture both sides):
168
+
169
+ Add to `~/.claude/settings.json`:
170
+ ```json
171
+ {
172
+ "hooks": {
173
+ "UserPromptSubmit": [
174
+ {
175
+ "hooks": [
176
+ {
177
+ "type": "command",
178
+ "command": "npx agent-bridge-mcp --capture-prompt",
179
+ "timeout": 5
180
+ }
181
+ ]
182
+ }
183
+ ],
184
+ "Stop": [
185
+ {
186
+ "hooks": [
187
+ {
188
+ "type": "command",
189
+ "command": "npx agent-bridge-mcp --ingest",
190
+ "timeout": 10
191
+ }
192
+ ]
193
+ }
194
+ ]
195
+ }
196
+ }
197
+ ```
198
+
199
+ Set `AGENT_BRIDGE_NAME` in your project's `.mcp.json` so the ingest knows which agent name to write as.
200
+
201
+ Context is stored as the last 20 exchanges per agent, accessible via `get_context`.
202
+
203
+ ## Automatic Message Delivery (UserPromptSubmit Hook)
204
+
205
+ The `--check-messages` flag automatically delivers pending messages to an agent before every prompt — no manual `read_messages` calls needed.
206
+
207
+ **How it works:** Every time you send a prompt to any tab, the hook checks for new messages addressed to that agent. If there are any, they get injected into the conversation as context so Claude sees them immediately. A cursor file tracks what's already been delivered so messages are never repeated.
208
+
209
+ **Setup:**
210
+
211
+ Add to the `UserPromptSubmit` hooks in `~/.claude/settings.json`:
212
+ ```json
213
+ {
214
+ "type": "command",
215
+ "command": "npx agent-bridge-mcp --check-messages",
216
+ "timeout": 5
217
+ }
218
+ ```
219
+
220
+ The agent name is resolved automatically from the project's `.mcp.json` (reads `AGENT_BRIDGE_NAME` from the agent-bridge MCP config).
221
+
222
+ ## Session Bootstrap (SessionStart Hook)
223
+
224
+ The `--bootstrap-context` flag loads shared context from all other agents when a new session starts — so new tabs immediately know what everyone else knows.
225
+
226
+ **How it works:** When a Claude Code session starts or resumes, the hook reads all shared context files and lists active agents, then injects everything into the conversation. The agent starts with full awareness of what other agents have been working on.
227
+
228
+ **Setup:**
229
+
230
+ Add a `SessionStart` hook to `~/.claude/settings.json`:
231
+ ```json
232
+ {
233
+ "hooks": {
234
+ "SessionStart": [
235
+ {
236
+ "hooks": [
237
+ {
238
+ "type": "command",
239
+ "command": "npx agent-bridge-mcp --bootstrap-context",
240
+ "timeout": 10
241
+ }
242
+ ]
243
+ }
244
+ ]
245
+ }
246
+ }
247
+ ```
248
+
249
+ ## Full Hook Setup
250
+
251
+ Here's the complete `~/.claude/settings.json` hooks configuration with all agent-bridge features enabled:
252
+
253
+ ```json
254
+ {
255
+ "hooks": {
256
+ "SessionStart": [
257
+ {
258
+ "hooks": [
259
+ {
260
+ "type": "command",
261
+ "command": "npx agent-bridge-mcp --bootstrap-context",
262
+ "timeout": 10
263
+ }
264
+ ]
265
+ }
266
+ ],
267
+ "UserPromptSubmit": [
268
+ {
269
+ "hooks": [
270
+ {
271
+ "type": "command",
272
+ "command": "npx agent-bridge-mcp --capture-prompt",
273
+ "timeout": 5
274
+ },
275
+ {
276
+ "type": "command",
277
+ "command": "npx agent-bridge-mcp --check-messages",
278
+ "timeout": 5
279
+ }
280
+ ]
281
+ }
282
+ ],
283
+ "Stop": [
284
+ {
285
+ "hooks": [
286
+ {
287
+ "type": "command",
288
+ "command": "npx agent-bridge-mcp --ingest",
289
+ "timeout": 10
290
+ }
291
+ ]
292
+ }
293
+ ]
294
+ }
295
+ }
296
+ ```
297
+
157
298
  ## Requirements
158
299
 
159
300
  - Node.js >= 18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-bridge-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.4.0",
4
4
  "description": "MCP server enabling communication between multiple Claude Code sessions via shared filesystem message bus",
5
5
  "type": "module",
6
6
  "main": "server.mjs",
package/server.mjs CHANGED
@@ -35,6 +35,7 @@ const BRIDGE_DIR = process.env.AGENT_BRIDGE_DATA_DIR || getDefaultDataDir();
35
35
  const AGENTS_DIR = resolve(BRIDGE_DIR, 'agents');
36
36
  const MESSAGES_DIR = resolve(BRIDGE_DIR, 'messages');
37
37
  const CONTEXT_DIR = resolve(BRIDGE_DIR, 'context');
38
+ const CURSORS_DIR = resolve(BRIDGE_DIR, 'cursors');
38
39
  const HEARTBEAT_INTERVAL_MS = parseInt(process.env.AGENT_BRIDGE_HEARTBEAT_MS || '15000', 10);
39
40
  const STALE_THRESHOLD_MS = HEARTBEAT_INTERVAL_MS * 3;
40
41
  const DEAD_THRESHOLD_MS = parseInt(process.env.AGENT_BRIDGE_DEAD_MS || '300000', 10);
@@ -46,6 +47,7 @@ const MESSAGE_TTL_MS = parseInt(process.env.AGENT_BRIDGE_MESSAGE_TTL_MS || '3600
46
47
 
47
48
  const agentId = `agent-${randomBytes(3).toString('hex')}`;
48
49
  let agentName = process.env.AGENT_BRIDGE_NAME || null;
50
+ let agentBaseName = process.env.AGENT_BRIDGE_NAME || null; // Original name before auto-suffix
49
51
  const startedAt = new Date().toISOString();
50
52
  let lastReadTimestamp = Date.now(); // only read messages after we start
51
53
  let heartbeatTimer = null;
@@ -58,6 +60,7 @@ function ensureDirs() {
58
60
  mkdirSync(AGENTS_DIR, { recursive: true });
59
61
  mkdirSync(MESSAGES_DIR, { recursive: true });
60
62
  mkdirSync(CONTEXT_DIR, { recursive: true });
63
+ mkdirSync(CURSORS_DIR, { recursive: true });
61
64
  }
62
65
 
63
66
  // ============================================================
@@ -81,6 +84,7 @@ function getAgentData() {
81
84
  return {
82
85
  id: agentId,
83
86
  name: agentName,
87
+ baseName: agentBaseName || agentName,
84
88
  project: process.cwd().split(/[\\/]/).pop(),
85
89
  cwd: process.cwd(),
86
90
  pid: process.pid,
@@ -281,17 +285,47 @@ function resolveAgent(nameOrId) {
281
285
  const byId = agents.find(a => a.id === nameOrId);
282
286
  if (byId) return byId;
283
287
 
284
- // Exact name match (case-insensitive)
285
- const byName = agents.find(a => a.name && a.name.toLowerCase() === nameOrId.toLowerCase());
286
- if (byName) return byName;
288
+ // Exact name match (case-insensitive) — pick most recently active if multiple
289
+ const nameL = nameOrId.toLowerCase();
290
+ const byName = agents
291
+ .filter(a => a.name && a.name.toLowerCase() === nameL)
292
+ .sort((a, b) => new Date(b.lastHeartbeat) - new Date(a.lastHeartbeat));
293
+ if (byName.length > 0) return byName[0];
294
+
295
+ // Also match by baseName for auto-suffixed agents
296
+ const byBase = agents
297
+ .filter(a => a.baseName && a.baseName.toLowerCase() === nameL)
298
+ .sort((a, b) => new Date(b.lastHeartbeat) - new Date(a.lastHeartbeat));
299
+ if (byBase.length > 0) return byBase[0];
287
300
 
288
301
  // Partial name match
289
- const partial = agents.filter(a => a.name && a.name.toLowerCase().includes(nameOrId.toLowerCase()));
302
+ const partial = agents.filter(a => a.name && a.name.toLowerCase().includes(nameL));
290
303
  if (partial.length === 1) return partial[0];
291
304
 
292
305
  return null;
293
306
  }
294
307
 
308
+ // Resolve ALL agents matching a name (for multi-session messaging)
309
+ function resolveAllAgents(nameOrId) {
310
+ const agents = getAllAgents().filter(a => !a.isMe && a.status === 'active');
311
+ const nameL = nameOrId.toLowerCase();
312
+
313
+ // Exact ID match — single result
314
+ const byId = agents.find(a => a.id === nameOrId);
315
+ if (byId) return [byId];
316
+
317
+ // Match by name or baseName
318
+ const matches = agents.filter(a =>
319
+ (a.name && a.name.toLowerCase() === nameL) ||
320
+ (a.baseName && a.baseName.toLowerCase() === nameL)
321
+ );
322
+ if (matches.length > 0) return matches;
323
+
324
+ // Partial name match
325
+ const partial = agents.filter(a => a.name && a.name.toLowerCase().includes(nameL));
326
+ return partial;
327
+ }
328
+
295
329
  // ============================================================
296
330
  // Tool Handlers
297
331
  // ============================================================
@@ -300,26 +334,32 @@ async function handleRegister(args) {
300
334
  const name = args.name?.trim();
301
335
  if (!name) return { error: 'Name is required' };
302
336
 
303
- // Check uniqueness
337
+ // Allow duplicate base names — auto-suffix if taken
304
338
  const agents = getAllAgents().filter(a => !a.isMe && a.status === 'active');
305
- const conflict = agents.find(a => a.name && a.name.toLowerCase() === name.toLowerCase());
306
- if (conflict) {
307
- return {
308
- error: `Name "${name}" is already taken by ${conflict.id} (project: ${conflict.project})`,
309
- suggestion: `Try "${name}-2" or a different name`
310
- };
339
+ let finalName = name;
340
+ const nameLower = name.toLowerCase();
341
+ const conflicts = agents.filter(a => a.name && a.name.toLowerCase() === nameLower);
342
+ if (conflicts.length > 0) {
343
+ // Find next available suffix
344
+ const allNames = agents.map(a => a.name?.toLowerCase()).filter(Boolean);
345
+ let suffix = 2;
346
+ while (allNames.includes(`${nameLower}-${suffix}`)) suffix++;
347
+ finalName = `${name}-${suffix}`;
311
348
  }
312
349
 
313
- agentName = name;
350
+ agentName = finalName;
351
+ agentBaseName = name; // Track original base name for context grouping
314
352
  writeAgentFile();
315
353
 
316
354
  return {
317
355
  registered: true,
318
356
  id: agentId,
319
357
  name: agentName,
358
+ baseName: name,
320
359
  project: process.cwd().split(/[\\/]/).pop(),
321
360
  cwd: process.cwd(),
322
- pid: process.pid
361
+ pid: process.pid,
362
+ ...(finalName !== name ? { note: `Name "${name}" was taken, registered as "${finalName}"` } : {})
323
363
  };
324
364
  }
325
365
 
@@ -386,16 +426,18 @@ async function handleBroadcast(args) {
386
426
  // Context Operations
387
427
  // ============================================================
388
428
 
389
- function writeContextFile(name, content) {
429
+ function writeContextFile(name, content, uniqueId) {
390
430
  const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
431
+ const suffix = uniqueId || agentId;
391
432
  const data = JSON.stringify({
392
433
  agentName: name,
393
- agentId,
434
+ agentId: uniqueId || agentId,
435
+ baseName: name,
394
436
  project: process.cwd().split(/[\\/]/).pop(),
395
437
  updatedAt: new Date().toISOString(),
396
438
  content
397
439
  }, null, 2);
398
- const targetPath = resolve(CONTEXT_DIR, `${safeName}.json`);
440
+ const targetPath = resolve(CONTEXT_DIR, `${safeName}--${suffix}.json`);
399
441
  const tmpPath = targetPath + '.tmp';
400
442
  try {
401
443
  writeFileSync(tmpPath, data);
@@ -444,15 +486,34 @@ async function handleGetContext(args) {
444
486
  const { from } = args;
445
487
 
446
488
  if (from) {
447
- // Get context from a specific agent
489
+ // Get ALL contexts matching this base name (handles multiple sessions)
448
490
  const safeName = from.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
449
- const filePath = resolve(CONTEXT_DIR, `${safeName}.json`);
491
+ let files;
450
492
  try {
451
- const ctx = JSON.parse(readFileSync(filePath, 'utf8'));
452
- return { found: true, context: ctx };
493
+ files = readdirSync(CONTEXT_DIR).filter(f => f.endsWith('.json'));
453
494
  } catch {
454
495
  return { found: false, error: `No shared context found for "${from}"` };
455
496
  }
497
+
498
+ const matches = [];
499
+ for (const file of files) {
500
+ // Match: exact old-style "{name}.json" OR new-style "{name}--{id}.json"
501
+ const basePart = file.replace(/\.json$/, '').split('--')[0];
502
+ if (basePart === safeName) {
503
+ try {
504
+ const ctx = JSON.parse(readFileSync(resolve(CONTEXT_DIR, file), 'utf8'));
505
+ matches.push(ctx);
506
+ } catch { continue; }
507
+ }
508
+ }
509
+
510
+ if (matches.length === 0) {
511
+ return { found: false, error: `No shared context found for "${from}"` };
512
+ }
513
+ if (matches.length === 1) {
514
+ return { found: true, context: matches[0] };
515
+ }
516
+ return { found: true, contexts: matches, count: matches.length };
456
517
  }
457
518
 
458
519
  // Get all shared contexts
@@ -463,6 +524,108 @@ async function handleGetContext(args) {
463
524
  };
464
525
  }
465
526
 
527
+ // ============================================================
528
+ // Hook Helpers (used by --check-messages and --bootstrap-context)
529
+ // ============================================================
530
+
531
+ /**
532
+ * Figure out the agent name for this project.
533
+ * Checks: 1) AGENT_BRIDGE_NAME env var, 2) .mcp.json in the cwd, 3) agent files matching cwd
534
+ */
535
+ function resolveAgentName(cwd) {
536
+ if (process.env.AGENT_BRIDGE_NAME) return process.env.AGENT_BRIDGE_NAME;
537
+
538
+ // Read .mcp.json from the project directory
539
+ if (cwd) {
540
+ const mcpPath = resolve(cwd, '.mcp.json');
541
+ try {
542
+ const mcp = JSON.parse(readFileSync(mcpPath, 'utf8'));
543
+ const bridge = mcp.mcpServers?.['agent-bridge'];
544
+ if (bridge?.env?.AGENT_BRIDGE_NAME) return bridge.env.AGENT_BRIDGE_NAME;
545
+ } catch {}
546
+ }
547
+
548
+ // Fallback: scan agent files for matching cwd
549
+ try {
550
+ const files = readdirSync(AGENTS_DIR).filter(f => f.endsWith('.json'));
551
+ for (const file of files) {
552
+ const agent = readAgentFile(resolve(AGENTS_DIR, file));
553
+ if (agent?.name && agent.cwd === cwd) return agent.name;
554
+ }
555
+ } catch {}
556
+
557
+ return null;
558
+ }
559
+
560
+ /**
561
+ * Read messages for a specific agent, starting from a cursor timestamp.
562
+ * Returns { messages: [...], newCursor: number }
563
+ */
564
+ function readMessagesForAgent(name, cursorTimestamp) {
565
+ let files;
566
+ try {
567
+ files = readdirSync(MESSAGES_DIR).filter(f => f.endsWith('.json')).sort();
568
+ } catch {
569
+ return { messages: [], newCursor: cursorTimestamp };
570
+ }
571
+
572
+ const pending = [];
573
+ let newCursor = cursorTimestamp;
574
+ const nameLower = name.toLowerCase();
575
+
576
+ for (const file of files) {
577
+ const timestamp = parseInt(file.split('-')[0], 10);
578
+ if (isNaN(timestamp) || timestamp <= cursorTimestamp) continue;
579
+
580
+ const filePath = resolve(MESSAGES_DIR, file);
581
+ let msg;
582
+ try { msg = JSON.parse(readFileSync(filePath, 'utf8')); } catch { continue; }
583
+
584
+ // Skip messages FROM this agent
585
+ if (msg.fromName?.toLowerCase() === nameLower) continue;
586
+
587
+ // Check if addressed to this agent or is a broadcast
588
+ const isForMe = msg.toName?.toLowerCase() === nameLower || msg.to?.toLowerCase() === nameLower;
589
+ const isBroadcast = msg.broadcast && msg.to === '*';
590
+
591
+ if (isForMe || isBroadcast) {
592
+ pending.push(msg);
593
+ }
594
+
595
+ if (timestamp > newCursor) newCursor = timestamp;
596
+ }
597
+
598
+ return { messages: pending, newCursor };
599
+ }
600
+
601
+ /**
602
+ * Read the cursor file for an agent. Returns the timestamp of the last processed message.
603
+ */
604
+ function readCursor(name) {
605
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
606
+ const cursorPath = resolve(CURSORS_DIR, `${safeName}.cursor`);
607
+ try {
608
+ return parseInt(readFileSync(cursorPath, 'utf8').trim(), 10) || 0;
609
+ } catch {
610
+ return 0;
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Write the cursor file for an agent.
616
+ */
617
+ function writeCursor(name, timestamp) {
618
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
619
+ const cursorPath = resolve(CURSORS_DIR, `${safeName}.cursor`);
620
+ const tmpPath = cursorPath + '.tmp';
621
+ try {
622
+ writeFileSync(tmpPath, String(timestamp));
623
+ renameSync(tmpPath, cursorPath);
624
+ } catch {
625
+ try { writeFileSync(cursorPath, String(timestamp)); } catch {}
626
+ }
627
+ }
628
+
466
629
  // ============================================================
467
630
  // Tool Definitions
468
631
  // ============================================================
@@ -586,7 +749,7 @@ async function handleToolCall(name, args) {
586
749
  // ============================================================
587
750
 
588
751
  const server = new Server(
589
- { name: 'agent-bridge', version: '1.1.0' },
752
+ { name: 'agent-bridge', version: '1.4.0' },
590
753
  { capabilities: { tools: {} } }
591
754
  );
592
755
 
@@ -621,34 +784,365 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
621
784
  });
622
785
 
623
786
  // ============================================================
624
- // Main
787
+ // CLI: --capture-prompt (UserPromptSubmit hook mode)
625
788
  // ============================================================
789
+ // Saves user prompt to temp file for --ingest to pair with.
790
+ // Usage: node server.mjs --capture-prompt
791
+
792
+ async function runCapturePrompt() {
793
+ const chunks = [];
794
+ for await (const chunk of process.stdin) chunks.push(chunk);
795
+ const raw = Buffer.concat(chunks).toString('utf8');
796
+
797
+ let data;
798
+ try { data = JSON.parse(raw); } catch { process.exit(0); }
799
+
800
+ const sessionId = data.session_id || '';
801
+ const prompt = data.prompt || '';
802
+ if (!sessionId || !prompt) process.exit(0);
803
+
804
+ // Strip system tags
805
+ const tagPatterns = [
806
+ /<system-reminder>[\s\S]*?<\/system-reminder>/g,
807
+ /<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g,
808
+ /<ide_selection>[\s\S]*?<\/ide_selection>/g,
809
+ /<task-notification>[\s\S]*?<\/task-notification>/g,
810
+ ];
811
+ let clean = prompt;
812
+ for (const pat of tagPatterns) clean = clean.replace(pat, '');
813
+ clean = clean.replace(/\n\s*\n/g, '\n').trim();
814
+
815
+ if (clean.length < 5) process.exit(0);
816
+
817
+ const tmp = tmpdir();
818
+ writeFileSync(resolve(tmp, `agent-bridge-prompt-${sessionId}`), clean, 'utf8');
819
+ process.exit(0);
820
+ }
626
821
 
627
- async function main() {
822
+ // ============================================================
823
+ // CLI: --ingest (Stop hook mode)
824
+ // ============================================================
825
+ // Called by Claude Code Stop hook. Reads stdin JSON with
826
+ // session_id + last_assistant_message, writes to context dir.
827
+ // Usage: node server.mjs --ingest
828
+
829
+ async function runIngest() {
628
830
  ensureDirs();
629
- cleanOldMessages();
630
- writeAgentFile();
631
- startHeartbeat();
632
831
 
633
- // Cleanup on exit
634
- const cleanup = () => {
635
- if (heartbeatTimer) clearInterval(heartbeatTimer);
636
- removeAgentFile();
832
+ // Read stdin
833
+ const chunks = [];
834
+ for await (const chunk of process.stdin) chunks.push(chunk);
835
+ const raw = Buffer.concat(chunks).toString('utf8');
836
+
837
+ let data;
838
+ try {
839
+ data = JSON.parse(raw);
840
+ } catch {
841
+ process.exit(0);
842
+ }
843
+
844
+ const sessionId = data.session_id || '';
845
+ const assistantMsg = data.last_assistant_message || '';
846
+ if (!sessionId || !assistantMsg) process.exit(0);
847
+
848
+ // Read user prompt saved by --capture-prompt hook
849
+ const tmp = tmpdir();
850
+ const promptPath = resolve(tmp, `agent-bridge-prompt-${sessionId}`);
851
+ let userMsg = '';
852
+ try {
853
+ userMsg = readFileSync(promptPath, 'utf8');
854
+ } catch {}
855
+
856
+ // Strip system tags
857
+ const tagPatterns = [
858
+ /<system-reminder>[\s\S]*?<\/system-reminder>/g,
859
+ /<task-notification>[\s\S]*?<\/task-notification>/g,
860
+ /<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g,
861
+ /<ide_selection>[\s\S]*?<\/ide_selection>/g,
862
+ ];
863
+ let cleanAssistant = assistantMsg;
864
+ for (const pat of tagPatterns) cleanAssistant = cleanAssistant.replace(pat, '');
865
+ cleanAssistant = cleanAssistant.replace(/\n\s*\n/g, '\n').trim();
866
+
867
+ if (!cleanAssistant || cleanAssistant.length < 50) process.exit(0);
868
+
869
+ // Determine agent name from env or from active agent files
870
+ const name = process.env.AGENT_BRIDGE_NAME || findAgentNameForSession(sessionId);
871
+ if (!name) process.exit(0);
872
+
873
+ // Read existing context and append new exchange
874
+ // Use session-specific filename so multiple sessions don't overwrite each other
875
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
876
+ const ctxPath = resolve(CONTEXT_DIR, `${safeName}--${sessionId}.json`);
877
+
878
+ // Also check for legacy file (no session suffix) and migrate it
879
+ const legacyPath = resolve(CONTEXT_DIR, `${safeName}.json`);
880
+
881
+ let existing = { exchanges: [] };
882
+ try {
883
+ const prev = JSON.parse(readFileSync(ctxPath, 'utf8'));
884
+ if (prev.exchanges) existing = prev;
885
+ else if (prev.content) existing = { exchanges: [{ role: 'context', content: prev.content }] };
886
+ } catch {
887
+ // If no session-specific file exists, DON'T inherit from legacy file
888
+ // (it belongs to a different session)
889
+ }
890
+
891
+ // Keep last 20 exchanges to avoid unbounded growth
892
+ existing.exchanges.push({
893
+ timestamp: new Date().toISOString(),
894
+ user: userMsg.slice(0, 2000),
895
+ assistant: cleanAssistant.slice(0, 5000)
896
+ });
897
+ if (existing.exchanges.length > 20) {
898
+ existing.exchanges = existing.exchanges.slice(-20);
899
+ }
900
+
901
+ const ctxData = JSON.stringify({
902
+ agentName: name,
903
+ baseName: name,
904
+ project: process.cwd().split(/[\\/]/).pop(),
905
+ updatedAt: new Date().toISOString(),
906
+ sessionId,
907
+ exchanges: existing.exchanges
908
+ }, null, 2);
909
+
910
+ const tmpPath = ctxPath + '.tmp';
911
+ try {
912
+ writeFileSync(tmpPath, ctxData);
913
+ renameSync(tmpPath, ctxPath);
914
+ } catch {
915
+ try { writeFileSync(ctxPath, ctxData); } catch {}
916
+ }
917
+
918
+ process.exit(0);
919
+ }
920
+
921
+ function findAgentNameForSession(sessionId) {
922
+ // Look through agent files to find one from this process's cwd
923
+ try {
924
+ const files = readdirSync(AGENTS_DIR).filter(f => f.endsWith('.json'));
925
+ const cwd = process.cwd();
926
+ for (const file of files) {
927
+ const agent = readAgentFile(resolve(AGENTS_DIR, file));
928
+ if (agent && agent.name && agent.cwd === cwd) return agent.name;
929
+ }
930
+ } catch {}
931
+ return null;
932
+ }
933
+
934
+ // ============================================================
935
+ // CLI: --check-messages (UserPromptSubmit hook mode)
936
+ // ============================================================
937
+ // Runs on every prompt. Checks for new messages addressed to this agent
938
+ // and outputs them as additionalContext so Claude sees them automatically.
939
+ // Usage: node server.mjs --check-messages
940
+
941
+ async function runCheckMessages() {
942
+ ensureDirs();
943
+
944
+ // Read stdin for hook context
945
+ const chunks = [];
946
+ for await (const chunk of process.stdin) chunks.push(chunk);
947
+ const raw = Buffer.concat(chunks).toString('utf8');
948
+
949
+ let data;
950
+ try { data = JSON.parse(raw); } catch { process.exit(0); }
951
+
952
+ const cwd = data.cwd || process.cwd();
953
+ const name = resolveAgentName(cwd);
954
+ if (!name) process.exit(0);
955
+
956
+ // Read cursor and scan for new messages
957
+ const cursor = readCursor(name);
958
+ const { messages, newCursor } = readMessagesForAgent(name, cursor);
959
+
960
+ // Update cursor even if no messages (advances past scanned files)
961
+ if (newCursor > cursor) {
962
+ writeCursor(name, newCursor);
963
+ }
964
+
965
+ if (messages.length === 0) {
966
+ process.exit(0); // No output = no context injection
967
+ }
968
+
969
+ // Format messages for Claude
970
+ const formatted = messages.map(m => {
971
+ const from = m.fromName || m.from;
972
+ const age = Math.round((Date.now() - new Date(m.timestamp).getTime()) / 1000);
973
+ const ageStr = age < 60 ? `${age}s ago` : `${Math.round(age / 60)}min ago`;
974
+ return `[MESSAGE FROM ${from}]: ${m.content} (${ageStr})`;
975
+ }).join('\n\n');
976
+
977
+ const output = {
978
+ hookSpecificOutput: {
979
+ hookEventName: data.hook_event_name || 'UserPromptSubmit',
980
+ additionalContext: `--- INCOMING AGENT MESSAGES (${messages.length}) ---\n${formatted}\n--- END AGENT MESSAGES ---\nYou received ${messages.length} message(s) from other agents. Read and respond to them. Use send_message to reply if needed.`
981
+ }
637
982
  };
638
- process.on('exit', cleanup);
639
- process.on('SIGTERM', () => { cleanup(); process.exit(0); });
640
- process.on('SIGINT', () => { cleanup(); process.exit(0); });
641
983
 
642
- // Connect stdio transport
643
- const transport = new StdioServerTransport();
644
- await server.connect(transport);
984
+ process.stdout.write(JSON.stringify(output));
985
+ process.exit(0);
986
+ }
987
+
988
+ // ============================================================
989
+ // CLI: --bootstrap-context (SessionStart hook mode)
990
+ // ============================================================
991
+ // Runs once when a new session starts. Reads all shared contexts
992
+ // from other agents and any pending messages, outputs them so
993
+ // Claude starts the session knowing what everyone else knows.
994
+ // Usage: node server.mjs --bootstrap-context
995
+
996
+ async function runBootstrapContext() {
997
+ ensureDirs();
998
+
999
+ // Read stdin for hook context
1000
+ const chunks = [];
1001
+ for await (const chunk of process.stdin) chunks.push(chunk);
1002
+ const raw = Buffer.concat(chunks).toString('utf8');
1003
+
1004
+ let data;
1005
+ try { data = JSON.parse(raw); } catch { process.exit(0); }
645
1006
 
646
- console.error(`[AgentBridge] Server running`);
647
- console.error(`[AgentBridge] Agent ID: ${agentId}`);
648
- console.error(`[AgentBridge] Data dir: ${BRIDGE_DIR}`);
1007
+ const cwd = data.cwd || process.cwd();
1008
+ const myName = resolveAgentName(cwd);
1009
+ if (!myName) process.exit(0);
1010
+
1011
+ const myNameLower = myName.toLowerCase();
1012
+
1013
+ // Get active agents (excluding self)
1014
+ let activeAgents = [];
1015
+ try {
1016
+ const agentFiles = readdirSync(AGENTS_DIR).filter(f => f.endsWith('.json'));
1017
+ for (const file of agentFiles) {
1018
+ try {
1019
+ const agent = JSON.parse(readFileSync(resolve(AGENTS_DIR, file), 'utf8'));
1020
+ if (agent.name && agent.name.toLowerCase() !== myNameLower && isProcessAlive(agent.pid)) {
1021
+ activeAgents.push(agent);
1022
+ }
1023
+ } catch {}
1024
+ }
1025
+ } catch {}
1026
+
1027
+ // Get all shared contexts (excluding own session only, NOT all same-name sessions)
1028
+ // Use sessionId to filter — other sessions with the same name should still be visible
1029
+ const mySessionId = data.session_id || '';
1030
+ const contexts = readAllContextFiles().filter(c => {
1031
+ // Exclude our own session's context, but keep other sessions even if same name
1032
+ if (c.sessionId && mySessionId && c.sessionId === mySessionId) return false;
1033
+ // Fallback: if no sessionId, exclude exact agentId match
1034
+ if (c.agentId && c.agentId === myName) return false;
1035
+ return true;
1036
+ });
1037
+
1038
+ // Check for pending messages too
1039
+ const cursor = readCursor(myName);
1040
+ const { messages, newCursor } = readMessagesForAgent(myName, cursor);
1041
+ if (newCursor > cursor) {
1042
+ writeCursor(myName, newCursor);
1043
+ }
1044
+
1045
+ // Nothing to bootstrap
1046
+ if (activeAgents.length === 0 && contexts.length === 0 && messages.length === 0) {
1047
+ process.exit(0);
1048
+ }
1049
+
1050
+ // Build the bootstrap output
1051
+ const parts = [];
1052
+
1053
+ if (activeAgents.length > 0) {
1054
+ parts.push(`ACTIVE AGENTS: ${activeAgents.map(a => `${a.name} (${a.project})`).join(', ')}`);
1055
+ }
1056
+
1057
+ for (const ctx of contexts) {
1058
+ const ageMin = Math.round((Date.now() - new Date(ctx.updatedAt).getTime()) / 1000 / 60);
1059
+ let summary = '';
1060
+ if (ctx.content) {
1061
+ summary = ctx.content.slice(0, 3000);
1062
+ } else if (ctx.exchanges && ctx.exchanges.length > 0) {
1063
+ // Show last 3 exchanges as summary
1064
+ const recent = ctx.exchanges.slice(-3);
1065
+ summary = recent.map(e =>
1066
+ `User: ${(e.user || '').slice(0, 200)}\nAssistant: ${(e.assistant || '').slice(0, 500)}`
1067
+ ).join('\n---\n');
1068
+ }
1069
+ if (summary) {
1070
+ parts.push(`--- CONTEXT FROM ${ctx.agentName} (${ctx.project || 'unknown'}, updated ${ageMin}min ago) ---\n${summary}`);
1071
+ }
1072
+ }
1073
+
1074
+ if (messages.length > 0) {
1075
+ const formatted = messages.map(m => {
1076
+ const from = m.fromName || m.from;
1077
+ const age = Math.round((Date.now() - new Date(m.timestamp).getTime()) / 1000);
1078
+ const ageStr = age < 60 ? `${age}s ago` : `${Math.round(age / 60)}min ago`;
1079
+ return `[MESSAGE FROM ${from}]: ${m.content} (${ageStr})`;
1080
+ }).join('\n\n');
1081
+ parts.push(`--- PENDING MESSAGES (${messages.length}) ---\n${formatted}`);
1082
+ }
1083
+
1084
+ const output = {
1085
+ hookSpecificOutput: {
1086
+ hookEventName: data.hook_event_name || 'SessionStart',
1087
+ additionalContext: `--- AGENT BRIDGE BOOTSTRAP ---\nYou are "${myName}". The agent-bridge connects you with other Claude Code sessions. Messages from other agents are delivered automatically before each prompt.\n\n${parts.join('\n\n')}\n--- END BOOTSTRAP ---`
1088
+ }
1089
+ };
1090
+
1091
+ process.stdout.write(JSON.stringify(output));
1092
+ process.exit(0);
649
1093
  }
650
1094
 
651
- main().catch(err => {
652
- console.error(`[AgentBridge] Fatal: ${err.message}`);
653
- process.exit(1);
654
- });
1095
+ // ============================================================
1096
+ // Main
1097
+ // ============================================================
1098
+
1099
+ if (process.argv.includes('--capture-prompt')) {
1100
+ runCapturePrompt().catch(() => process.exit(0));
1101
+ } else if (process.argv.includes('--ingest')) {
1102
+ runIngest().catch(() => process.exit(0));
1103
+ } else if (process.argv.includes('--check-messages')) {
1104
+ runCheckMessages().catch(() => process.exit(0));
1105
+ } else if (process.argv.includes('--bootstrap-context')) {
1106
+ runBootstrapContext().catch(() => process.exit(0));
1107
+ } else {
1108
+ async function main() {
1109
+ ensureDirs();
1110
+ cleanOldMessages();
1111
+
1112
+ // Auto-suffix name if env var conflicts with an existing active agent
1113
+ if (agentName) {
1114
+ const existing = getAllAgents().filter(a => a.status === 'active' && a.name?.toLowerCase() === agentName.toLowerCase());
1115
+ if (existing.length > 0) {
1116
+ const allNames = getAllAgents().map(a => a.name?.toLowerCase()).filter(Boolean);
1117
+ let suffix = 2;
1118
+ while (allNames.includes(`${agentName.toLowerCase()}-${suffix}`)) suffix++;
1119
+ agentName = `${agentBaseName}-${suffix}`;
1120
+ }
1121
+ }
1122
+
1123
+ writeAgentFile();
1124
+ startHeartbeat();
1125
+
1126
+ // Cleanup on exit
1127
+ const cleanup = () => {
1128
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
1129
+ removeAgentFile();
1130
+ };
1131
+ process.on('exit', cleanup);
1132
+ process.on('SIGTERM', () => { cleanup(); process.exit(0); });
1133
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
1134
+
1135
+ // Connect stdio transport
1136
+ const transport = new StdioServerTransport();
1137
+ await server.connect(transport);
1138
+
1139
+ console.error(`[AgentBridge] Server running`);
1140
+ console.error(`[AgentBridge] Agent ID: ${agentId}`);
1141
+ console.error(`[AgentBridge] Data dir: ${BRIDGE_DIR}`);
1142
+ }
1143
+
1144
+ main().catch(err => {
1145
+ console.error(`[AgentBridge] Fatal: ${err.message}`);
1146
+ process.exit(1);
1147
+ });
1148
+ }