multiagents 0.1.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.
@@ -0,0 +1,1493 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * BaseAdapter — Abstract base class for all agent MCP adapters.
4
+ *
5
+ * Extracts ALL shared logic from the original server.ts into a reusable
6
+ * base that Claude, Codex, Gemini, and custom adapters extend.
7
+ *
8
+ * Subclasses override:
9
+ * - deliverMessage(msg) — how inbound messages reach the agent
10
+ * - getSystemPrompt() — agent-specific MCP instructions
11
+ * - getCapabilities() — MCP capability declaration
12
+ */
13
+
14
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import {
17
+ ListToolsRequestSchema,
18
+ CallToolRequestSchema,
19
+ } from "@modelcontextprotocol/sdk/types.js";
20
+ import { BrokerClient } from "../shared/broker-client.ts";
21
+ import type {
22
+ AgentType,
23
+ PeerId,
24
+ Peer,
25
+ Slot,
26
+ BufferedMessage,
27
+ RegisterResponse,
28
+ PollMessagesResponse,
29
+ Message,
30
+ SessionFile,
31
+ SendMessageResult,
32
+ AcquireFileResult,
33
+ FileLock,
34
+ FileOwnership,
35
+ } from "../shared/types.ts";
36
+ import {
37
+ POLL_INTERVALS,
38
+ HEARTBEAT_INTERVAL,
39
+ BROKER_HOSTNAME,
40
+ DEFAULT_BROKER_PORT,
41
+ BROKER_STARTUP_POLL_MS,
42
+ BROKER_STARTUP_MAX_ATTEMPTS,
43
+ SESSION_FILE,
44
+ } from "../shared/constants.ts";
45
+ import {
46
+ log as sharedLog,
47
+ getGitRoot,
48
+ getTty,
49
+ safeJsonParse,
50
+ formatTime,
51
+ timeSince,
52
+ } from "../shared/utils.ts";
53
+ import {
54
+ generateSummary,
55
+ getGitBranch,
56
+ getRecentFiles,
57
+ } from "../shared/summarize.ts";
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Tool definitions shared by all adapters
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const TOOLS = [
64
+ {
65
+ name: "list_peers",
66
+ description:
67
+ "List other agent instances. Returns ID, Name, Type, Role, CWD, Summary, Last seen.",
68
+ inputSchema: {
69
+ type: "object" as const,
70
+ properties: {
71
+ scope: {
72
+ type: "string" as const,
73
+ enum: ["machine", "directory", "repo"],
74
+ description:
75
+ '"machine" = all instances. "directory" = same CWD. "repo" = same git repo.',
76
+ },
77
+ agent_type: {
78
+ type: "string" as const,
79
+ enum: ["claude", "codex", "gemini", "custom", "all"],
80
+ description: "Optional filter by agent type. Defaults to all.",
81
+ },
82
+ },
83
+ required: ["scope"],
84
+ },
85
+ },
86
+ {
87
+ name: "send_message",
88
+ description: "Send a message to another agent instance by peer ID.",
89
+ inputSchema: {
90
+ type: "object" as const,
91
+ properties: {
92
+ to_id: {
93
+ type: "string" as const,
94
+ description: "Target peer ID (from list_peers).",
95
+ },
96
+ message: {
97
+ type: "string" as const,
98
+ description: "The message text to send.",
99
+ },
100
+ },
101
+ required: ["to_id", "message"],
102
+ },
103
+ },
104
+ {
105
+ name: "set_summary",
106
+ description:
107
+ "Set a 1-2 sentence summary of your current work (visible to peers).",
108
+ inputSchema: {
109
+ type: "object" as const,
110
+ properties: {
111
+ summary: {
112
+ type: "string" as const,
113
+ description: "A brief summary of your current work.",
114
+ },
115
+ },
116
+ required: ["summary"],
117
+ },
118
+ },
119
+ {
120
+ name: "check_messages",
121
+ description: "Manually poll for new messages from other agents.",
122
+ inputSchema: {
123
+ type: "object" as const,
124
+ properties: {},
125
+ },
126
+ },
127
+ {
128
+ name: "assign_role",
129
+ description: "Assign a role and description to a peer.",
130
+ inputSchema: {
131
+ type: "object" as const,
132
+ properties: {
133
+ peer_id: { type: "string" as const, description: "Target peer ID." },
134
+ role: { type: "string" as const, description: "Short role label." },
135
+ role_description: {
136
+ type: "string" as const,
137
+ description: "Detailed role description.",
138
+ },
139
+ },
140
+ required: ["peer_id", "role", "role_description"],
141
+ },
142
+ },
143
+ {
144
+ name: "rename_peer",
145
+ description: "Set or change a peer's display name.",
146
+ inputSchema: {
147
+ type: "object" as const,
148
+ properties: {
149
+ peer_id: { type: "string" as const, description: "Target peer ID." },
150
+ display_name: {
151
+ type: "string" as const,
152
+ description: "New display name.",
153
+ },
154
+ },
155
+ required: ["peer_id", "display_name"],
156
+ },
157
+ },
158
+ {
159
+ name: "acquire_file",
160
+ description: "Acquire an exclusive lock on a file for editing.",
161
+ inputSchema: {
162
+ type: "object" as const,
163
+ properties: {
164
+ file_path: {
165
+ type: "string" as const,
166
+ description: "Path to the file to lock.",
167
+ },
168
+ purpose: {
169
+ type: "string" as const,
170
+ description: "Why you need this file.",
171
+ },
172
+ },
173
+ required: ["file_path"],
174
+ },
175
+ },
176
+ {
177
+ name: "release_file",
178
+ description: "Release a file lock you hold.",
179
+ inputSchema: {
180
+ type: "object" as const,
181
+ properties: {
182
+ file_path: {
183
+ type: "string" as const,
184
+ description: "Path to the file to release.",
185
+ },
186
+ },
187
+ required: ["file_path"],
188
+ },
189
+ },
190
+ {
191
+ name: "view_file_locks",
192
+ description: "View all active file locks and ownership assignments.",
193
+ inputSchema: {
194
+ type: "object" as const,
195
+ properties: {},
196
+ },
197
+ },
198
+ {
199
+ name: "get_history",
200
+ description: "Retrieve message history from the session log.",
201
+ inputSchema: {
202
+ type: "object" as const,
203
+ properties: {
204
+ limit: {
205
+ type: "number" as const,
206
+ description: "Max messages to return (default 50).",
207
+ },
208
+ with_peer: {
209
+ type: "string" as const,
210
+ description: "Filter to messages involving this peer ID.",
211
+ },
212
+ since: {
213
+ type: "number" as const,
214
+ description: "Only messages after this epoch ms timestamp.",
215
+ },
216
+ },
217
+ },
218
+ },
219
+ {
220
+ name: "signal_done",
221
+ description:
222
+ "Signal that your current task is complete and ready for review. Do NOT call this prematurely — only when your implementation is truly done. After calling this, stay active and wait for feedback.",
223
+ inputSchema: {
224
+ type: "object" as const,
225
+ properties: {
226
+ summary: {
227
+ type: "string" as const,
228
+ description: "Brief summary of what you accomplished",
229
+ },
230
+ },
231
+ required: ["summary"],
232
+ },
233
+ },
234
+ {
235
+ name: "submit_feedback",
236
+ description:
237
+ "Send review feedback to another agent. Set actionable=true if changes are needed (sends agent back to work), false for informational comments.",
238
+ inputSchema: {
239
+ type: "object" as const,
240
+ properties: {
241
+ target: {
242
+ type: "string" as const,
243
+ description:
244
+ "Name, role, or slot ID of the agent to review",
245
+ },
246
+ feedback: {
247
+ type: "string" as const,
248
+ description:
249
+ "Your feedback — be specific and actionable",
250
+ },
251
+ actionable: {
252
+ type: "boolean" as const,
253
+ description:
254
+ "true if changes are required, false if just informational",
255
+ },
256
+ },
257
+ required: ["target", "feedback", "actionable"],
258
+ },
259
+ },
260
+ {
261
+ name: "approve",
262
+ description:
263
+ "Approve another agent's work. This signals that their implementation meets quality standards. Only call when you are satisfied with the work.",
264
+ inputSchema: {
265
+ type: "object" as const,
266
+ properties: {
267
+ target: {
268
+ type: "string" as const,
269
+ description:
270
+ "Name, role, or slot ID of the agent to approve",
271
+ },
272
+ message: {
273
+ type: "string" as const,
274
+ description: "Optional approval message",
275
+ },
276
+ },
277
+ required: ["target"],
278
+ },
279
+ },
280
+ {
281
+ name: "check_team_status",
282
+ description:
283
+ "See the full team status: every agent's role, connection state, task state, and what they are working on. Use this proactively to know who needs help, who is waiting for review, and who is blocked. Call this regularly.",
284
+ inputSchema: {
285
+ type: "object" as const,
286
+ properties: {},
287
+ },
288
+ },
289
+ {
290
+ name: "get_plan",
291
+ description:
292
+ "Get the session plan with all items, their IDs, statuses, and assignments. Call this to learn which plan items are assigned to you and what their IDs are, so you can update them with update_plan.",
293
+ inputSchema: {
294
+ type: "object" as const,
295
+ properties: {},
296
+ },
297
+ },
298
+ {
299
+ name: "update_plan",
300
+ description:
301
+ "Update a plan item's status (pending, in_progress, done, blocked). Use this to mark your assigned plan items as you complete them. Call with the item ID and new status. Call get_plan first to learn your item IDs.",
302
+ inputSchema: {
303
+ type: "object" as const,
304
+ properties: {
305
+ item_id: {
306
+ type: "number" as const,
307
+ description: "Plan item ID to update",
308
+ },
309
+ status: {
310
+ type: "string" as const,
311
+ enum: ["pending", "in_progress", "done", "blocked"],
312
+ description: "New status for the plan item",
313
+ },
314
+ },
315
+ required: ["item_id", "status"],
316
+ },
317
+ },
318
+ ];
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Role-specific best practices injection
322
+ // ---------------------------------------------------------------------------
323
+
324
+ interface RolePattern {
325
+ /** Keywords that match this role (checked against role name + description, case-insensitive) */
326
+ keywords: string[];
327
+ /** Best practices injected when role matches */
328
+ practices: string;
329
+ }
330
+
331
+ const ROLE_PRACTICES: RolePattern[] = [
332
+ {
333
+ keywords: ["android", "kotlin", "jetpack", "compose"],
334
+ practices: `ANDROID ENGINEERING:
335
+ - Use Kotlin with Jetpack Compose for UI. Follow MVVM architecture.
336
+ - Structure: feature-first modules. Each feature has ui/, data/, domain/ layers.
337
+ - Use StateFlow for state management. Avoid LiveData in new code.
338
+ - Build with Gradle. Ensure build.gradle.kts has correct compileSdk, minSdk, targetSdk.
339
+ - Test on Android Emulator: use "emulator" or "adb" commands to verify the app runs.
340
+ - Check logcat for crashes: "adb logcat *:E" to filter errors.
341
+ - Before signaling done: build succeeds (./gradlew assembleDebug), no lint errors, app launches on emulator.`,
342
+ },
343
+ {
344
+ keywords: ["ios", "swift", "swiftui", "xcode", "uikit"],
345
+ practices: `iOS ENGINEERING:
346
+ - Use Swift with SwiftUI for new UI. Follow MVVM or TCA architecture.
347
+ - Project structure: feature folders, each with Views/, ViewModels/, Models/.
348
+ - Use async/await for concurrency. Avoid callback-based patterns in new code.
349
+ - Build with xcodebuild or swift build. Ensure .xcodeproj or Package.swift is valid.
350
+ - Test on iOS Simulator: use "xcrun simctl" to boot simulator, install, and launch.
351
+ - Check for crashes: "xcrun simctl spawn booted log stream --level error".
352
+ - Before signaling done: build succeeds, no warnings treated as errors, app launches on simulator.`,
353
+ },
354
+ {
355
+ keywords: ["react", "frontend", "web", "nextjs", "next.js", "typescript", "javascript", "vue", "angular", "svelte"],
356
+ practices: `WEB/FRONTEND ENGINEERING:
357
+ - Use TypeScript strictly. No "any" types unless absolutely necessary.
358
+ - Follow the framework's conventions: file-based routing, server/client component boundaries.
359
+ - CSS: use the project's existing approach (Tailwind, CSS modules, styled-components).
360
+ - Accessibility: semantic HTML, ARIA labels, keyboard navigation, proper heading hierarchy.
361
+ - Performance: lazy load heavy components, optimize images, minimize client-side JS.
362
+ - Test in browser: start dev server, verify all routes render, check console for errors.
363
+ - Before signaling done: dev server runs without errors, no console warnings, responsive on mobile viewports.`,
364
+ },
365
+ {
366
+ keywords: ["backend", "api", "server", "microservice", "database", "python", "go", "rust", "java", "node"],
367
+ practices: `BACKEND ENGINEERING:
368
+ - Follow RESTful conventions or the project's existing API pattern (GraphQL, gRPC, etc.).
369
+ - Validate all inputs at system boundaries. Never trust client data.
370
+ - Error handling: return proper HTTP status codes, structured error responses.
371
+ - Database: use migrations, parameterized queries (never string concatenation), proper indexing.
372
+ - Security: no secrets in code, use environment variables, sanitize user input.
373
+ - Before signaling done: server starts, all endpoints respond correctly, no unhandled exceptions in logs.`,
374
+ },
375
+ {
376
+ keywords: ["qa", "tester", "test", "quality", "testing"],
377
+ practices: `QA / TESTING:
378
+ - Your job is to FIND BUGS, not confirm things work. Be adversarial.
379
+ - Test categories: functional correctness, edge cases, error handling, UI/UX, performance, security.
380
+ - For mobile apps: test on actual emulators/simulators, not just code review.
381
+ - Android: use "adb" to install APK, run the app, test user flows.
382
+ - iOS: use "xcrun simctl" to install and run on simulator.
383
+ - Web: open in browser, test responsive layouts, check console for errors.
384
+ - Bug reports must include: file:line (if code-level), reproduction steps, expected vs actual, severity.
385
+ - Severity levels: P0 (crash/data loss), P1 (major feature broken), P2 (minor issue), P3 (cosmetic).
386
+ - Re-test EVERY fix. Don't assume fixes are correct — verify them.
387
+ - Before approving: all P0/P1 issues resolved, app runs end-to-end on target platform without crashes.`,
388
+ },
389
+ {
390
+ keywords: ["reviewer", "review", "code review"],
391
+ practices: `CODE REVIEW:
392
+ - Review for: correctness, security, performance, readability, maintainability, test coverage.
393
+ - Check architecture: does this follow the project's patterns? Is it consistent with existing code?
394
+ - Security checklist: input validation, SQL injection, XSS, auth/authz, secrets exposure, dependency vulnerabilities.
395
+ - Performance: unnecessary re-renders, N+1 queries, missing indexes, large bundle imports.
396
+ - Provide actionable feedback with file paths and line numbers. Not "this could be better" but "this should use X because Y".
397
+ - Distinguish blocking issues (must fix) from suggestions (nice to have).
398
+ - Before approving: no security issues, no architectural violations, code is production-ready.`,
399
+ },
400
+ {
401
+ keywords: ["designer", "design", "ui/ux", "ux", "figma", "spec"],
402
+ practices: `DESIGN / UI SPECIFICATION:
403
+ - Produce a clear, implementable design specification — not vague descriptions.
404
+ - Spec must include: component hierarchy, layout (dimensions, spacing), colors (hex/tokens), typography (font, size, weight), states (default, hover, active, disabled, error, loading, empty).
405
+ - For each screen/component: describe the visual layout, user interactions, transitions/animations.
406
+ - Platform-specific considerations:
407
+ - Android: Material Design 3 guidelines, system back gesture, dynamic color.
408
+ - iOS: Human Interface Guidelines, safe areas, Dynamic Type, SF Symbols.
409
+ - Web: responsive breakpoints (mobile/tablet/desktop), accessibility, keyboard navigation.
410
+ - Deliver the spec as a structured document (Markdown) that engineers can implement from directly.
411
+ - Before signaling done: every screen has a spec, every interaction is described, edge cases covered (empty state, error state, loading state).`,
412
+ },
413
+ {
414
+ keywords: ["architect", "lead", "team lead", "tech lead", "principal"],
415
+ practices: `ARCHITECTURE / TEAM LEAD:
416
+ - Your primary job is COORDINATION and QUALITY, not implementation.
417
+ - Define the plan: break the project into tasks, assign to team members, set dependencies.
418
+ - Resolve conflicts: if two agents disagree or block each other, make the decision.
419
+ - Quality gate: review the overall integration — do all parts work together?
420
+ - Communication: keep the team aligned. Broadcast requirement changes immediately.
421
+ - When releasing agents: verify ALL work is integrated, tested, and production-grade.
422
+ - You decide when the team is done. Don't release prematurely.`,
423
+ },
424
+ {
425
+ keywords: ["devops", "infrastructure", "ci/cd", "deploy", "cloud"],
426
+ practices: `DEVOPS / INFRASTRUCTURE:
427
+ - Infrastructure as code: use declarative configs (Terraform, CloudFormation, Docker Compose).
428
+ - CI/CD: ensure builds are reproducible. Pin dependency versions. Cache aggressively.
429
+ - Security: no secrets in repos, use secret managers, principle of least privilege.
430
+ - Monitoring: set up health checks, log aggregation, alerting.
431
+ - Before signaling done: pipeline runs green, deployment succeeds, health checks pass.`,
432
+ },
433
+ ];
434
+
435
+ /**
436
+ * Match a role name + description against known patterns and return
437
+ * applicable best practices. Multiple patterns can match (e.g., "Android QA").
438
+ */
439
+ function getRolePractices(role?: string | null, roleDescription?: string | null): string | null {
440
+ if (!role && !roleDescription) return null;
441
+
442
+ const haystack = `${role ?? ""} ${roleDescription ?? ""}`.toLowerCase();
443
+ const matched: string[] = [];
444
+
445
+ for (const pattern of ROLE_PRACTICES) {
446
+ if (pattern.keywords.some(kw => haystack.includes(kw))) {
447
+ matched.push(pattern.practices);
448
+ }
449
+ }
450
+
451
+ return matched.length > 0 ? matched.join("\n\n") : null;
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // BaseAdapter
456
+ // ---------------------------------------------------------------------------
457
+
458
+ export abstract class BaseAdapter {
459
+ // --- Identity & state ---
460
+ protected myId: PeerId | null = null;
461
+ protected myCwd: string = process.cwd();
462
+ protected myGitRoot: string | null = null;
463
+ protected myTty: string | null = null;
464
+ protected mySlot: Slot | null = null;
465
+ protected sessionId: string | null = null;
466
+ protected sessionFile: SessionFile | null = null;
467
+ protected roleContext: string = "";
468
+
469
+ // --- Infrastructure ---
470
+ protected broker: BrokerClient;
471
+ protected mcp!: Server;
472
+ protected pollInterval: number;
473
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
474
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
475
+
476
+ protected readonly agentType: AgentType;
477
+ private readonly brokerPort: number;
478
+ private readonly brokerUrl: string;
479
+ private readonly brokerScript: string;
480
+
481
+ constructor(agentType: AgentType) {
482
+ this.agentType = agentType;
483
+ this.brokerPort = parseInt(
484
+ process.env.MULTIAGENTS_PORT ?? String(DEFAULT_BROKER_PORT),
485
+ 10,
486
+ );
487
+ this.brokerUrl = `http://${BROKER_HOSTNAME}:${this.brokerPort}`;
488
+ this.brokerScript = new URL("../broker.ts", import.meta.url).pathname;
489
+ this.broker = new BrokerClient(this.brokerUrl);
490
+ this.pollInterval = POLL_INTERVALS[agentType] ?? POLL_INTERVALS.custom;
491
+
492
+ // Read session file if present
493
+ this.readSessionFile();
494
+ }
495
+
496
+ // --- Abstract methods (subclasses MUST implement) ---
497
+
498
+ abstract deliverMessage(msg: BufferedMessage): Promise<void>;
499
+ abstract getSystemPrompt(): string;
500
+ abstract getCapabilities(): Record<string, unknown>;
501
+
502
+ // --- Main entry point ---
503
+
504
+ async start(): Promise<void> {
505
+ // === PHASE 1: MCP HANDSHAKE (must complete FAST) ===
506
+ // The MCP client (Claude/Codex/Gemini) connects to us over stdio and
507
+ // sends an initialize request. If we don't respond quickly, the client
508
+ // times out (Codex: 10s). So we create the MCP server, register tools,
509
+ // and connect FIRST — before any network calls to the broker.
510
+
511
+ this.myCwd = process.cwd();
512
+
513
+ // 1. Create MCP Server immediately
514
+ this.mcp = new Server(
515
+ { name: "multiagents", version: "0.2.0" },
516
+ {
517
+ capabilities: this.getCapabilities(),
518
+ instructions: this.getSystemPrompt() + this.getLifecyclePromptSection(),
519
+ },
520
+ );
521
+
522
+ // 2. Register tools (all tool handlers check this.myId and defer if not registered yet)
523
+ this.registerTools();
524
+
525
+ // 3. Connect MCP over stdio — this completes the handshake with the client
526
+ await this.mcp.connect(new StdioServerTransport());
527
+ this.log("MCP connected (handshake complete)");
528
+
529
+ // === PHASE 2: BROKER REGISTRATION (can take time, client is already connected) ===
530
+
531
+ // 4. Ensure broker is running
532
+ await this.ensureBroker();
533
+
534
+ // 5. Gather context
535
+ this.myGitRoot = await getGitRoot(this.myCwd);
536
+ this.myTty = getTty();
537
+
538
+ this.log(`CWD: ${this.myCwd}`);
539
+ this.log(`Git root: ${this.myGitRoot ?? "(none)"}`);
540
+ this.log(`TTY: ${this.myTty ?? "(unknown)"}`);
541
+
542
+ // 6. Generate initial summary (non-blocking, 3s timeout)
543
+ let initialSummary = "";
544
+ const summaryPromise = (async () => {
545
+ try {
546
+ const branch = await getGitBranch(this.myCwd);
547
+ const recentFiles = await getRecentFiles(this.myCwd);
548
+ const summary = await generateSummary({
549
+ cwd: this.myCwd,
550
+ git_root: this.myGitRoot,
551
+ git_branch: branch,
552
+ recent_files: recentFiles,
553
+ });
554
+ if (summary) {
555
+ initialSummary = summary;
556
+ this.log(`Auto-summary: ${summary}`);
557
+ }
558
+ } catch (e) {
559
+ this.log(
560
+ `Auto-summary failed (non-critical): ${e instanceof Error ? e.message : String(e)}`,
561
+ );
562
+ }
563
+ })();
564
+
565
+ await Promise.race([
566
+ summaryPromise,
567
+ new Promise((r) => setTimeout(r, 3000)),
568
+ ]);
569
+
570
+ // 7. Register with broker
571
+ const regBody: Record<string, unknown> = {
572
+ pid: process.pid,
573
+ cwd: this.myCwd,
574
+ git_root: this.myGitRoot,
575
+ tty: this.myTty,
576
+ summary: initialSummary,
577
+ agent_type: this.agentType,
578
+ };
579
+ if (this.sessionId) {
580
+ regBody.session_id = this.sessionId;
581
+ }
582
+ if (this.sessionFile) {
583
+ regBody.reconnect = true;
584
+ }
585
+ // Pass orchestrator-assigned slot/role for explicit slot targeting
586
+ const envSlot = process.env.MULTIAGENTS_SLOT;
587
+ const envRole = process.env.MULTIAGENTS_ROLE;
588
+ const envName = process.env.MULTIAGENTS_NAME;
589
+ if (envSlot) {
590
+ regBody.slot_id = parseInt(envSlot, 10);
591
+ }
592
+ if (envRole) {
593
+ regBody.role = envRole;
594
+ }
595
+ if (envName) {
596
+ regBody.display_name = envName;
597
+ }
598
+
599
+ const reg = await this.broker.register(regBody as any);
600
+ this.myId = reg.id;
601
+ this.log(`Registered as peer ${this.myId}`);
602
+
603
+ // Handle slot matching
604
+ if (reg.slot) {
605
+ this.mySlot = reg.slot;
606
+ this.log(`Matched to slot ${reg.slot.id} (${reg.slot.display_name ?? "unnamed"})`);
607
+ this.restoreRoleContext(reg.slot);
608
+ } else if ((reg as any).choose_slot) {
609
+ // Multiple slot candidates — pick by role or take first
610
+ const candidates = (reg as any).choose_slot as { slot_id: number; role: string | null }[];
611
+ const match = envRole
612
+ ? candidates.find((c) => c.role === envRole) ?? candidates[0]
613
+ : candidates[0];
614
+ if (match) {
615
+ this.log(`Auto-selecting slot ${match.slot_id} from ${candidates.length} candidates`);
616
+ try {
617
+ const claimed = await this.broker.updateSlot({
618
+ id: match.slot_id,
619
+ peer_id: reg.id,
620
+ status: "connected",
621
+ });
622
+ if (claimed) {
623
+ this.mySlot = claimed;
624
+ this.restoreRoleContext(claimed);
625
+ }
626
+ } catch (e) {
627
+ this.log(`Failed to claim slot ${match.slot_id}: ${e}`);
628
+ }
629
+ }
630
+ }
631
+
632
+ // Deliver recap messages if reconnecting
633
+ if (reg.recap && reg.recap.length > 0) {
634
+ this.log(`Delivering ${reg.recap.length} recap message(s)`);
635
+ for (const msg of reg.recap) {
636
+ const enriched = await this.enrichMessage(msg);
637
+ await this.deliverMessage(enriched);
638
+ }
639
+ }
640
+
641
+ // If summary generation is still running, update when done
642
+ if (!initialSummary) {
643
+ summaryPromise.then(async () => {
644
+ if (initialSummary && this.myId) {
645
+ try {
646
+ await this.broker.setSummary(this.myId, initialSummary);
647
+ this.log(`Late auto-summary applied: ${initialSummary}`);
648
+ } catch {
649
+ // Non-critical
650
+ }
651
+ }
652
+ });
653
+ }
654
+
655
+ // === PHASE 3: START BACKGROUND LOOPS ===
656
+
657
+ // 8. Start poll loop
658
+ this.startPollLoop();
659
+
660
+ // 9. Start heartbeat
661
+ this.startHeartbeat();
662
+
663
+ // 10. Cleanup on exit
664
+ const cleanup = async () => {
665
+ if (this.pollTimer) clearInterval(this.pollTimer);
666
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
667
+ if (this.myId) {
668
+ try {
669
+ const result = await this.broker.unregister(this.myId);
670
+ if (result.denied) {
671
+ // Cannot disconnect yet — task not released
672
+ this.log(`Disconnect denied: ${result.reason}`);
673
+ this.log(`Task state: ${result.task_state}. Staying connected and polling for messages.`);
674
+ // Restart poll loop to keep receiving messages
675
+ this.startPollLoop();
676
+ this.startHeartbeat();
677
+ return; // Do NOT exit
678
+ }
679
+ this.log("Unregistered from broker");
680
+ } catch { /* best effort */ }
681
+ }
682
+ process.exit(0);
683
+ };
684
+
685
+ process.on("SIGINT", cleanup);
686
+ process.on("SIGTERM", cleanup);
687
+ }
688
+
689
+ // --- Broker lifecycle ---
690
+
691
+ private async ensureBroker(): Promise<void> {
692
+ if (await this.broker.isAlive()) {
693
+ this.log("Broker already running");
694
+ return;
695
+ }
696
+
697
+ this.log("Starting broker daemon...");
698
+ const proc = Bun.spawn(["bun", this.brokerScript], {
699
+ stdio: ["ignore", "ignore", "inherit"],
700
+ });
701
+ proc.unref();
702
+
703
+ for (let i = 0; i < BROKER_STARTUP_MAX_ATTEMPTS; i++) {
704
+ await new Promise((r) => setTimeout(r, BROKER_STARTUP_POLL_MS));
705
+ if (await this.broker.isAlive()) {
706
+ this.log("Broker started");
707
+ return;
708
+ }
709
+ }
710
+ throw new Error("Failed to start broker daemon after 6 seconds");
711
+ }
712
+
713
+ // --- Session file ---
714
+
715
+ private readSessionFile(): void {
716
+ try {
717
+ const file = Bun.file(SESSION_FILE);
718
+ // Synchronous check — Bun.file doesn't throw if missing, but reading will
719
+ const text = require("fs").readFileSync(SESSION_FILE, "utf-8");
720
+ this.sessionFile = JSON.parse(text) as SessionFile;
721
+ this.sessionId = this.sessionFile.session_id;
722
+ this.log(`Session file found: ${this.sessionId}`);
723
+ } catch {
724
+ // No session file — standalone mode
725
+ }
726
+ }
727
+
728
+ // --- Role context restoration ---
729
+
730
+ private restoreRoleContext(slot: Slot): void {
731
+ const parts: string[] = [];
732
+ if (slot.role) {
733
+ parts.push(`Your assigned role: ${slot.role}`);
734
+ }
735
+ if (slot.role_description) {
736
+ parts.push(`Role description: ${slot.role_description}`);
737
+ }
738
+ if (slot.display_name) {
739
+ parts.push(`Your display name: ${slot.display_name}`);
740
+ }
741
+ if (slot.context_snapshot) {
742
+ const ctx = safeJsonParse<Record<string, string>>(
743
+ slot.context_snapshot,
744
+ {},
745
+ );
746
+ if (ctx.last_summary) {
747
+ parts.push(`Previous summary: ${ctx.last_summary}`);
748
+ }
749
+ }
750
+
751
+ // Inject role-specific best practices based on role category
752
+ const practices = getRolePractices(slot.role, slot.role_description);
753
+ if (practices) {
754
+ parts.push("");
755
+ parts.push("--- ROLE-SPECIFIC PRACTICES ---");
756
+ parts.push(practices);
757
+ }
758
+
759
+ if (parts.length > 0) {
760
+ this.roleContext = parts.join("\n");
761
+ }
762
+ }
763
+
764
+ // --- Tool registration ---
765
+
766
+ protected registerTools(): void {
767
+ this.mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
768
+ tools: TOOLS,
769
+ }));
770
+
771
+ this.mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
772
+ const { name, arguments: args } = req.params;
773
+
774
+ switch (name) {
775
+ case "list_peers":
776
+ return this.handleListPeers(args as any);
777
+ case "send_message":
778
+ return this.handleSendMessage(args as any);
779
+ case "set_summary":
780
+ return this.handleSetSummary(args as any);
781
+ case "check_messages":
782
+ return this.handleCheckMessages();
783
+ case "assign_role":
784
+ return this.handleAssignRole(args as any);
785
+ case "rename_peer":
786
+ return this.handleRenamePeer(args as any);
787
+ case "acquire_file":
788
+ return this.handleAcquireFile(args as any);
789
+ case "release_file":
790
+ return this.handleReleaseFile(args as any);
791
+ case "view_file_locks":
792
+ return this.handleViewFileLocks();
793
+ case "get_history":
794
+ return this.handleGetHistory(args as any);
795
+ case "signal_done":
796
+ return this.handleSignalDone(args as any);
797
+ case "submit_feedback":
798
+ return this.handleSubmitFeedback(args as any);
799
+ case "approve":
800
+ return this.handleApprove(args as any);
801
+ case "check_team_status":
802
+ return this.handleCheckTeamStatus();
803
+ case "get_plan":
804
+ return this.handleGetPlan();
805
+ case "update_plan":
806
+ return this.handleUpdatePlan(toolArgs as { item_id: number; status: string });
807
+ default:
808
+ throw new Error(`Unknown tool: ${name}`);
809
+ }
810
+ });
811
+ }
812
+
813
+ // --- Tool handlers (protected — subclasses can override) ---
814
+
815
+ protected async handleListPeers(args: {
816
+ scope: "machine" | "directory" | "repo";
817
+ agent_type?: AgentType | "all";
818
+ }) {
819
+ try {
820
+ const peers = await this.broker.listPeers({
821
+ scope: args.scope,
822
+ cwd: this.myCwd,
823
+ git_root: this.myGitRoot,
824
+ exclude_id: this.myId ?? undefined,
825
+ agent_type: args.agent_type,
826
+ session_id: this.sessionId ?? undefined,
827
+ });
828
+
829
+ if (peers.length === 0) {
830
+ return this.textResult(
831
+ `No other agent instances found (scope: ${args.scope}).`,
832
+ );
833
+ }
834
+
835
+ const lines = peers.map((p: Peer) => {
836
+ const parts = [`ID: ${p.id}`];
837
+ if ((p as any).display_name) parts.push(`Name: ${(p as any).display_name}`);
838
+ parts.push(`Type: ${p.agent_type}`);
839
+ if ((p as any).role) parts.push(`Role: ${(p as any).role}`);
840
+ parts.push(`CWD: ${p.cwd}`);
841
+ if (p.summary) parts.push(`Summary: ${p.summary}`);
842
+ parts.push(`Last seen: ${timeSince(p.last_seen)}`);
843
+ return parts.join("\n ");
844
+ });
845
+
846
+ return this.textResult(
847
+ `Found ${peers.length} peer(s) (scope: ${args.scope}):\n\n${lines.join("\n\n")}`,
848
+ );
849
+ } catch (e) {
850
+ return this.errorResult(
851
+ `Error listing peers: ${e instanceof Error ? e.message : String(e)}`,
852
+ );
853
+ }
854
+ }
855
+
856
+ protected async handleSendMessage(args: {
857
+ to_id: string;
858
+ message: string;
859
+ }) {
860
+ if (!this.myId) {
861
+ return this.errorResult("Not registered with broker yet");
862
+ }
863
+ try {
864
+ const result = await this.broker.sendMessage({
865
+ from_id: this.myId,
866
+ to_id: args.to_id,
867
+ text: args.message,
868
+ });
869
+ if (!result.ok) {
870
+ return this.errorResult(`Failed to send: ${result.error}`);
871
+ }
872
+ let text = `Message sent to peer ${args.to_id}`;
873
+ if (result.warning) text += ` (warning: ${result.warning})`;
874
+ return this.textResult(text);
875
+ } catch (e) {
876
+ return this.errorResult(
877
+ `Error sending message: ${e instanceof Error ? e.message : String(e)}`,
878
+ );
879
+ }
880
+ }
881
+
882
+ protected async handleSetSummary(args: { summary: string }) {
883
+ if (!this.myId) {
884
+ return this.errorResult("Not registered with broker yet");
885
+ }
886
+ try {
887
+ await this.broker.setSummary(this.myId, args.summary);
888
+ return this.textResult(`Summary updated: "${args.summary}"`);
889
+ } catch (e) {
890
+ return this.errorResult(
891
+ `Error setting summary: ${e instanceof Error ? e.message : String(e)}`,
892
+ );
893
+ }
894
+ }
895
+
896
+ protected async handleCheckMessages() {
897
+ if (!this.myId) {
898
+ return this.errorResult("Not registered with broker yet");
899
+ }
900
+ try {
901
+ const result = await this.broker.pollMessages(this.myId);
902
+ if (result.messages.length === 0) {
903
+ return this.textResult("No new messages.");
904
+ }
905
+ const formatted: string[] = [];
906
+ for (const msg of result.messages) {
907
+ const enriched = await this.enrichMessage(msg);
908
+ formatted.push(this.formatMessage(enriched));
909
+ }
910
+ return this.textResult(
911
+ `${result.messages.length} new message(s):\n\n${formatted.join("\n\n---\n\n")}`,
912
+ );
913
+ } catch (e) {
914
+ return this.errorResult(
915
+ `Error checking messages: ${e instanceof Error ? e.message : String(e)}`,
916
+ );
917
+ }
918
+ }
919
+
920
+ protected async handleAssignRole(args: {
921
+ peer_id: string;
922
+ role: string;
923
+ role_description: string;
924
+ }) {
925
+ if (!this.myId) {
926
+ return this.errorResult("Not registered with broker yet");
927
+ }
928
+ try {
929
+ await this.broker.setRole({
930
+ peer_id: args.peer_id,
931
+ assigner_id: this.myId,
932
+ role: args.role,
933
+ role_description: args.role_description,
934
+ });
935
+ return this.textResult(
936
+ `Role "${args.role}" assigned to peer ${args.peer_id}.`,
937
+ );
938
+ } catch (e) {
939
+ return this.errorResult(
940
+ `Error assigning role: ${e instanceof Error ? e.message : String(e)}`,
941
+ );
942
+ }
943
+ }
944
+
945
+ protected async handleRenamePeer(args: {
946
+ peer_id: string;
947
+ display_name: string;
948
+ }) {
949
+ if (!this.myId) {
950
+ return this.errorResult("Not registered with broker yet");
951
+ }
952
+ try {
953
+ await this.broker.renamePeer({
954
+ peer_id: args.peer_id,
955
+ assigner_id: this.myId,
956
+ display_name: args.display_name,
957
+ });
958
+ return this.textResult(
959
+ `Peer ${args.peer_id} renamed to "${args.display_name}".`,
960
+ );
961
+ } catch (e) {
962
+ return this.errorResult(
963
+ `Error renaming peer: ${e instanceof Error ? e.message : String(e)}`,
964
+ );
965
+ }
966
+ }
967
+
968
+ protected async handleAcquireFile(args: {
969
+ file_path: string;
970
+ purpose?: string;
971
+ }) {
972
+ if (!this.myId || !this.sessionId) {
973
+ return this.errorResult("Not registered or no session active");
974
+ }
975
+ try {
976
+ const result = await this.broker.acquireFile({
977
+ session_id: this.sessionId,
978
+ peer_id: this.myId,
979
+ slot_id: this.mySlot?.id ?? 0,
980
+ file_path: args.file_path,
981
+ purpose: args.purpose,
982
+ });
983
+ if (result.status === "acquired" || result.status === "extended") {
984
+ return this.textResult(result.message);
985
+ }
986
+ return this.errorResult(result.message);
987
+ } catch (e) {
988
+ return this.errorResult(
989
+ `Error acquiring file: ${e instanceof Error ? e.message : String(e)}`,
990
+ );
991
+ }
992
+ }
993
+
994
+ protected async handleReleaseFile(args: { file_path: string }) {
995
+ if (!this.myId || !this.sessionId) {
996
+ return this.errorResult("Not registered or no session active");
997
+ }
998
+ try {
999
+ await this.broker.releaseFile({
1000
+ session_id: this.sessionId,
1001
+ peer_id: this.myId,
1002
+ file_path: args.file_path,
1003
+ });
1004
+ return this.textResult(`Released lock on ${args.file_path}.`);
1005
+ } catch (e) {
1006
+ return this.errorResult(
1007
+ `Error releasing file: ${e instanceof Error ? e.message : String(e)}`,
1008
+ );
1009
+ }
1010
+ }
1011
+
1012
+ protected async handleViewFileLocks() {
1013
+ if (!this.sessionId) {
1014
+ return this.errorResult("No session active");
1015
+ }
1016
+ try {
1017
+ const [locks, ownership] = await Promise.all([
1018
+ this.broker.listFileLocks(this.sessionId),
1019
+ this.broker.listFileOwnership(this.sessionId),
1020
+ ]);
1021
+
1022
+ const parts: string[] = [];
1023
+
1024
+ if (locks.length === 0) {
1025
+ parts.push("No active file locks.");
1026
+ } else {
1027
+ parts.push(`Active locks (${locks.length}):`);
1028
+ for (const lock of locks) {
1029
+ parts.push(
1030
+ ` ${lock.file_path} — held by slot ${lock.held_by_slot} (${lock.lock_type}) — ${lock.purpose ?? "no purpose"}`,
1031
+ );
1032
+ }
1033
+ }
1034
+
1035
+ if (ownership.length > 0) {
1036
+ parts.push(`\nFile ownership (${ownership.length}):`);
1037
+ for (const own of ownership) {
1038
+ parts.push(
1039
+ ` ${own.path_pattern} — slot ${own.slot_id} (assigned by ${own.assigned_by})`,
1040
+ );
1041
+ }
1042
+ }
1043
+
1044
+ return this.textResult(parts.join("\n"));
1045
+ } catch (e) {
1046
+ return this.errorResult(
1047
+ `Error viewing file locks: ${e instanceof Error ? e.message : String(e)}`,
1048
+ );
1049
+ }
1050
+ }
1051
+
1052
+ protected async handleGetHistory(args: {
1053
+ limit?: number;
1054
+ with_peer?: string;
1055
+ since?: number;
1056
+ }) {
1057
+ if (!this.sessionId) {
1058
+ return this.errorResult("No session active");
1059
+ }
1060
+ try {
1061
+ const messages = await this.broker.getMessageLog(this.sessionId, {
1062
+ limit: args.limit ?? 50,
1063
+ since: args.since,
1064
+ });
1065
+
1066
+ if (messages.length === 0) {
1067
+ return this.textResult("No message history found.");
1068
+ }
1069
+
1070
+ const lines = messages.map(
1071
+ (m) =>
1072
+ `[${formatTime(m.sent_at)}] ${m.from_id} -> ${m.to_id}: ${m.text}`,
1073
+ );
1074
+ return this.textResult(
1075
+ `Message history (${messages.length}):\n\n${lines.join("\n")}`,
1076
+ );
1077
+ } catch (e) {
1078
+ return this.errorResult(
1079
+ `Error getting history: ${e instanceof Error ? e.message : String(e)}`,
1080
+ );
1081
+ }
1082
+ }
1083
+
1084
+ // --- Lifecycle tool handlers ---
1085
+
1086
+ protected async handleSignalDone(args: { summary: string }): Promise<{ content: Array<{ type: string; text: string }> }> {
1087
+ if (!this.sessionId) {
1088
+ return { content: [{ type: "text", text: "No active session. Cannot signal done outside a session." }] };
1089
+ }
1090
+ const result = await this.broker.signalDone({
1091
+ peer_id: this.myId!,
1092
+ session_id: this.sessionId,
1093
+ summary: args.summary,
1094
+ });
1095
+
1096
+ // Auto-mark all plan items assigned to this slot as done
1097
+ if (this.mySlot?.id) {
1098
+ try {
1099
+ const plan = await this.broker.getPlan(this.sessionId);
1100
+ if (plan?.items) {
1101
+ for (const item of plan.items) {
1102
+ if (item.assigned_to_slot === this.mySlot.id && item.status !== "done") {
1103
+ await this.broker.updatePlanItem({ item_id: item.id, status: "done" });
1104
+ }
1105
+ }
1106
+ }
1107
+ } catch {
1108
+ // Non-critical — plan tracking is best-effort
1109
+ }
1110
+ }
1111
+
1112
+ return {
1113
+ content: [{
1114
+ type: "text",
1115
+ text: `Task state: ${result.task_state}. Your team has been notified. Stay active — you may receive feedback that requires changes. Do NOT disconnect.`,
1116
+ }],
1117
+ };
1118
+ }
1119
+
1120
+ protected async handleSubmitFeedback(args: { target: string; feedback: string; actionable: boolean }): Promise<{ content: Array<{ type: string; text: string }> }> {
1121
+ if (!this.sessionId) {
1122
+ return { content: [{ type: "text", text: "No active session." }] };
1123
+ }
1124
+ const targetSlot = await this.resolveTargetSlot(args.target);
1125
+ if (!targetSlot) {
1126
+ return { content: [{ type: "text", text: `Could not find agent "${args.target}". Use list_peers to see available agents.` }] };
1127
+ }
1128
+ const result = await this.broker.submitFeedback({
1129
+ peer_id: this.myId!,
1130
+ session_id: this.sessionId,
1131
+ target_slot_id: targetSlot.id,
1132
+ feedback: args.feedback,
1133
+ actionable: args.actionable,
1134
+ });
1135
+ const action = args.actionable ? "Agent sent back to address feedback." : "Informational feedback sent.";
1136
+ return {
1137
+ content: [{ type: "text", text: `Feedback sent to ${targetSlot.display_name || targetSlot.role || targetSlot.id}. ${action} Task state: ${result.task_state}` }],
1138
+ };
1139
+ }
1140
+
1141
+ protected async handleApprove(args: { target: string; message?: string }): Promise<{ content: Array<{ type: string; text: string }> }> {
1142
+ if (!this.sessionId) {
1143
+ return { content: [{ type: "text", text: "No active session." }] };
1144
+ }
1145
+ const targetSlot = await this.resolveTargetSlot(args.target);
1146
+ if (!targetSlot) {
1147
+ return { content: [{ type: "text", text: `Could not find agent "${args.target}".` }] };
1148
+ }
1149
+ const result = await this.broker.approve({
1150
+ peer_id: this.myId!,
1151
+ session_id: this.sessionId,
1152
+ target_slot_id: targetSlot.id,
1153
+ message: args.message,
1154
+ });
1155
+ return {
1156
+ content: [{ type: "text", text: `Approved ${targetSlot.display_name || targetSlot.role}. Task state: ${result.task_state}` }],
1157
+ };
1158
+ }
1159
+
1160
+ protected async handleCheckTeamStatus(): Promise<{ content: Array<{ type: string; text: string }> }> {
1161
+ if (!this.sessionId) {
1162
+ // Fallback: show all peers
1163
+ const peers = await this.broker.listPeers({ scope: "machine", cwd: this.myCwd, git_root: this.myGitRoot });
1164
+ if (peers.length === 0) return { content: [{ type: "text", text: "No teammates found." }] };
1165
+ const lines = peers.map(p =>
1166
+ `${p.id} (${p.agent_type}) — ${p.summary || "no summary"}`
1167
+ );
1168
+ return { content: [{ type: "text", text: `Peers on this machine:\n${lines.join("\n")}` }] };
1169
+ }
1170
+
1171
+ const slots = await this.broker.listSlots(this.sessionId);
1172
+ if (slots.length === 0) return { content: [{ type: "text", text: "No team members in session." }] };
1173
+
1174
+ const lines = slots.map(s => {
1175
+ const name = s.display_name || s.id;
1176
+ const role = s.role || "no role";
1177
+ const conn = s.status === "connected" ? "ONLINE" : "OFFLINE";
1178
+ const task = s.task_state || "idle";
1179
+ const paused = (s.paused === true || (s.paused as unknown as number) === 1) ? " [PAUSED]" : "";
1180
+ const isMe = s.peer_id === this.myId ? " (you)" : "";
1181
+ return ` ${name} | ${s.agent_type} | ${role} | ${conn}${paused} | task: ${task}${isMe}`;
1182
+ });
1183
+
1184
+ const header = `Team Status (${slots.filter(s => s.status === "connected").length}/${slots.length} online):`;
1185
+ const needsReview = slots.filter(s => s.task_state === "done_pending_review");
1186
+ const addressingFb = slots.filter(s => s.task_state === "addressing_feedback");
1187
+
1188
+ let actionItems = "";
1189
+ if (needsReview.length > 0) {
1190
+ actionItems += `\n\nAWAITING REVIEW: ${needsReview.map(s => s.display_name || s.role || s.id).join(", ")} — review their work now!`;
1191
+ }
1192
+ if (addressingFb.length > 0) {
1193
+ actionItems += `\nADDRESSING FEEDBACK: ${addressingFb.map(s => s.display_name || s.role || s.id).join(", ")} — be ready to re-review.`;
1194
+ }
1195
+
1196
+ return {
1197
+ content: [{ type: "text", text: `${header}\n${lines.join("\n")}${actionItems}` }],
1198
+ };
1199
+ }
1200
+
1201
+ protected async handleGetPlan() {
1202
+ if (!this.sessionId) {
1203
+ return { content: [{ type: "text", text: "No active session — no plan available." }] };
1204
+ }
1205
+ try {
1206
+ const result = await this.broker.getPlan(this.sessionId);
1207
+ if (!result.plan || result.items.length === 0) {
1208
+ return { content: [{ type: "text", text: "No plan defined for this session." }] };
1209
+ }
1210
+
1211
+ const mySlotId = this.mySlot?.id;
1212
+ const lines = [`Plan: ${result.plan.title} (${result.completion}% complete)\n`];
1213
+ for (const item of result.items) {
1214
+ const marker = item.status === "done" ? "[x]" : item.status === "in_progress" ? "[~]" : "[ ]";
1215
+ const assignee = item.assigned_name ?? "unassigned";
1216
+ const isMe = item.assigned_to_slot === mySlotId;
1217
+ const youTag = isMe ? " ← YOU" : "";
1218
+ lines.push(` ${marker} #${item.id}: ${item.label} (${assignee})${youTag}`);
1219
+ }
1220
+ lines.push(`\nTo update: call update_plan with item_id and status ("in_progress" or "done")`);
1221
+
1222
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1223
+ } catch (e) {
1224
+ return { content: [{ type: "text", text: `Failed to get plan: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
1225
+ }
1226
+ }
1227
+
1228
+ protected async handleUpdatePlan(args: { item_id: number; status: string }) {
1229
+ if (!this.sessionId) {
1230
+ return { content: [{ type: "text", text: "No active session — cannot update plan." }] };
1231
+ }
1232
+ try {
1233
+ const result = await this.broker.updatePlanItem({
1234
+ item_id: args.item_id,
1235
+ status: args.status,
1236
+ session_id: this.sessionId,
1237
+ }) as any;
1238
+
1239
+ if (result.plan) {
1240
+ return {
1241
+ content: [{ type: "text", text: `Plan item #${args.item_id} → ${args.status}. Plan completion: ${result.completion}%` }],
1242
+ };
1243
+ }
1244
+ return { content: [{ type: "text", text: `Plan item #${args.item_id} updated to ${args.status}.` }] };
1245
+ } catch (e) {
1246
+ return {
1247
+ content: [{ type: "text", text: `Failed to update plan: ${e instanceof Error ? e.message : String(e)}` }],
1248
+ isError: true,
1249
+ };
1250
+ }
1251
+ }
1252
+
1253
+ private async resolveTargetSlot(target: string): Promise<Slot | null> {
1254
+ if (!this.sessionId) return null;
1255
+ const slots = await this.broker.listSlots(this.sessionId);
1256
+ // Try exact name match
1257
+ let match = slots.find(s => s.display_name?.toLowerCase() === target.toLowerCase());
1258
+ if (match) return match;
1259
+ // Try role match
1260
+ match = slots.find(s => s.role?.toLowerCase() === target.toLowerCase());
1261
+ if (match) return match;
1262
+ // Try slot ID
1263
+ match = slots.find(s => String(s.id) === target);
1264
+ if (match) return match;
1265
+ // Try fuzzy
1266
+ match = slots.find(s =>
1267
+ s.display_name?.toLowerCase().includes(target.toLowerCase()) ||
1268
+ s.role?.toLowerCase().includes(target.toLowerCase())
1269
+ );
1270
+ return match ?? null;
1271
+ }
1272
+
1273
+ // --- Poll / heartbeat lifecycle ---
1274
+
1275
+ private startPollLoop(): void {
1276
+ if (this.pollTimer) clearInterval(this.pollTimer);
1277
+ this.pollTimer = setInterval(() => this.pollLoop(), this.pollInterval);
1278
+ }
1279
+
1280
+ private startHeartbeat(): void {
1281
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
1282
+ this.heartbeatTimer = setInterval(async () => {
1283
+ if (this.myId) {
1284
+ try {
1285
+ await this.broker.heartbeat(this.myId);
1286
+ } catch {
1287
+ // Non-critical
1288
+ }
1289
+ }
1290
+ }, HEARTBEAT_INTERVAL);
1291
+ }
1292
+
1293
+ // --- Poll loop ---
1294
+
1295
+ protected async pollLoop(): Promise<void> {
1296
+ if (!this.myId) return;
1297
+
1298
+ try {
1299
+ const result = await this.broker.pollMessages(this.myId);
1300
+
1301
+ // Fetch peer list once per poll iteration instead of per-message
1302
+ let peers: Peer[] | undefined;
1303
+ if (result.messages.length > 0) {
1304
+ try {
1305
+ peers = await this.broker.listPeers({
1306
+ scope: "machine",
1307
+ cwd: this.myCwd,
1308
+ git_root: this.myGitRoot,
1309
+ });
1310
+ } catch {
1311
+ // Non-critical — proceed without peer info
1312
+ }
1313
+ }
1314
+
1315
+ for (const msg of result.messages) {
1316
+ const enriched = await this.enrichMessage(msg, peers);
1317
+ await this.deliverMessage(enriched);
1318
+ this.log(
1319
+ `Delivered message from ${enriched.from_display_name ?? enriched.from_id}: ${msg.text.slice(0, 80)}`,
1320
+ );
1321
+ }
1322
+ } catch (e) {
1323
+ this.log(
1324
+ `Poll error: ${e instanceof Error ? e.message : String(e)}`,
1325
+ );
1326
+ }
1327
+ }
1328
+
1329
+ // --- Message enrichment ---
1330
+
1331
+ protected async enrichMessage(msg: Message, cachedPeers?: Peer[]): Promise<BufferedMessage> {
1332
+ const enriched: BufferedMessage = { ...msg };
1333
+ try {
1334
+ const peers = cachedPeers ?? await this.broker.listPeers({
1335
+ scope: "machine",
1336
+ cwd: this.myCwd,
1337
+ git_root: this.myGitRoot,
1338
+ });
1339
+ const sender = peers.find((p: Peer) => p.id === msg.from_id);
1340
+ if (sender) {
1341
+ enriched.from_display_name = (sender as any).display_name ?? null;
1342
+ enriched.from_agent_type = sender.agent_type;
1343
+ enriched.from_summary = sender.summary || null;
1344
+ enriched.from_cwd = sender.cwd;
1345
+ enriched.from_role = (sender as any).role ?? null;
1346
+ }
1347
+ } catch {
1348
+ // Non-critical — proceed without sender info
1349
+ }
1350
+ return enriched;
1351
+ }
1352
+
1353
+ // --- Message formatting ---
1354
+
1355
+ formatMessage(msg: BufferedMessage): string {
1356
+ const name =
1357
+ msg.from_display_name ?? msg.from_id;
1358
+ const role = msg.from_role ? ` (${msg.from_role})` : "";
1359
+
1360
+ switch (msg.msg_type) {
1361
+ case "chat":
1362
+ return `${name}${role}: ${msg.text}`;
1363
+ case "role_assignment":
1364
+ return `ROLE ASSIGNED: ${msg.text}`;
1365
+ case "rename":
1366
+ return `You have been named: ${msg.text}`;
1367
+ case "broadcast":
1368
+ return `BROADCAST: ${msg.text}`;
1369
+ case "team_change":
1370
+ return `TEAM CHANGE: ${msg.text}`;
1371
+ case "control":
1372
+ return msg.text;
1373
+ case "system":
1374
+ return msg.text;
1375
+ default:
1376
+ return `[${msg.msg_type}] ${name}: ${msg.text}`;
1377
+ }
1378
+ }
1379
+
1380
+ // --- Lifecycle prompt ---
1381
+
1382
+ protected getLifecyclePromptSection(): string {
1383
+ return `
1384
+
1385
+ === AGENT OPERATING SYSTEM ===
1386
+
1387
+ You are a team member in a multi-agent session. The system enforces that you CANNOT disconnect until explicitly released. This is non-negotiable and enforced server-side.
1388
+
1389
+ --- 1. PLANNING ---
1390
+
1391
+ Before writing any code, PLAN:
1392
+ - For tasks with 3+ steps: outline your approach in a message to the team before starting.
1393
+ - Write detailed specs for non-trivial work. Ambiguity causes rework.
1394
+ - If something breaks or your approach fails: STOP. Re-plan. Do not brute-force.
1395
+ - Update the plan as you learn: call update_plan when items change status.
1396
+
1397
+ --- 2. EXECUTION ---
1398
+
1399
+ Work autonomously and decisively:
1400
+ - Break your task into steps. Complete each fully before moving on.
1401
+ - Use acquire_file BEFORE editing any shared file. Release when done.
1402
+ - Prefer simple, clean solutions. Ask yourself: "Is there a simpler way?"
1403
+ - No TODO comments, no placeholder logic, no "fix later" patterns.
1404
+ - Match complexity to the task — don't overengineer small changes, don't underengineer critical ones.
1405
+
1406
+ --- 3. VERIFICATION BEFORE DONE ---
1407
+
1408
+ NEVER call signal_done without proof your work is correct:
1409
+ - Run the code. Check the output. Test edge cases.
1410
+ - Compare expected vs actual behavior.
1411
+ - Check for: missing error handling, untested paths, hardcoded values, security issues.
1412
+ - Ask yourself: "Would a senior engineer approve this as production-ready?"
1413
+ - If you cannot verify (no test runner, no simulator), explain what you verified manually.
1414
+
1415
+ signal_done summary MUST include: what changed, what was tested, what the results were.
1416
+
1417
+ --- 4. FEEDBACK & SELF-IMPROVEMENT ---
1418
+
1419
+ When you receive feedback:
1420
+ - Address EVERY item. Do not skip or defer.
1421
+ - Trace the root cause — don't patch symptoms.
1422
+ - Before re-submitting, review ALL prior feedback to ensure you haven't reintroduced old issues.
1423
+ - Internalize patterns: if you made a mistake, don't repeat it in this session.
1424
+ - Then call signal_done again with a clear diff of what you fixed.
1425
+
1426
+ When you give feedback (reviewers/QA):
1427
+ - Be specific: file paths, line numbers, reproduction steps, severity.
1428
+ - Distinguish blocking issues (actionable=true) from suggestions (actionable=false).
1429
+ - "Looks good" is not feedback. Cite specific files, line numbers, test results, and what you verified.
1430
+
1431
+ --- 5. TEAM AWARENESS ---
1432
+
1433
+ Stay aware and proactive:
1434
+ - Call check_messages frequently — new work arrives at any time.
1435
+ - Call check_team_status to see who needs help, who is blocked, who is waiting for review.
1436
+ - If a teammate signals done and your role involves review/QA: START IMMEDIATELY. Do not wait to be asked.
1437
+ - If you see a teammate stuck: message them with specific help, not "need help?".
1438
+ - If you have suggestions that improve overall quality, message the relevant teammate.
1439
+
1440
+ --- 6. BUG FIXING ---
1441
+
1442
+ When bugs are reported to you:
1443
+ - Investigate autonomously. Trace logs, read error output, find the root cause.
1444
+ - Do NOT ask "what should I do?" — you are the expert on your code.
1445
+ - Fix the root cause, not the symptom.
1446
+ - Verify the fix resolves the issue AND doesn't break other things.
1447
+ - Then signal_done with the fix summary.
1448
+
1449
+ --- 7. HANDOFF PROTOCOL ---
1450
+
1451
+ Engineers: implement -> verify -> signal_done -> receive feedback -> fix -> verify -> signal_done -> repeat until approved.
1452
+ QA/Testers: watch for task_complete -> test/verify -> submit_feedback or approve -> watch for re-submissions.
1453
+ Reviewers: watch for task_complete -> review code quality, correctness, security -> submit_feedback or approve -> re-review after fixes.
1454
+ Team Lead: coordinates priorities, resolves conflicts, releases agents when ALL work is production-grade.
1455
+
1456
+ --- 8. COMMUNICATION STANDARDS ---
1457
+
1458
+ - Lead with the answer, not the reasoning. Be concise.
1459
+ - When reporting status: what you did, what the result was, what's next.
1460
+ - When blocked: state what you need, from whom, and what you'll do while waiting.
1461
+ - Respond to ALL teammate messages promptly.
1462
+ - Use send_message for targeted communication. Use signal_done for completion signals.
1463
+
1464
+ --- 9. STAYING ACTIVE ---
1465
+
1466
+ The system DENIES disconnect attempts until you are released. While waiting:
1467
+ - Check messages and team status every few seconds.
1468
+ - Look for teammates who need unblocking.
1469
+ - Look for review/QA work you can start.
1470
+ - If truly nothing to do: set your summary to describe your availability and what you can help with.
1471
+ - NEVER go silent. The team depends on your responsiveness.
1472
+ `;
1473
+ }
1474
+
1475
+ // --- Helpers ---
1476
+
1477
+ protected log(msg: string): void {
1478
+ sharedLog(`multiagents`, msg);
1479
+ }
1480
+
1481
+ protected textResult(text: string) {
1482
+ return {
1483
+ content: [{ type: "text" as const, text }],
1484
+ };
1485
+ }
1486
+
1487
+ protected errorResult(text: string) {
1488
+ return {
1489
+ content: [{ type: "text" as const, text }],
1490
+ isError: true,
1491
+ };
1492
+ }
1493
+ }