@kernel.chat/kbot 2.23.2 → 2.24.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.
package/dist/team.js ADDED
@@ -0,0 +1,917 @@
1
+ // K:BOT Team Mode — Coordinated multi-instance collaboration over local TCP
2
+ //
3
+ // Usage:
4
+ // kbot team start # Start server + join as coordinator
5
+ // kbot team start --port 8000 # Custom port
6
+ // kbot team join --role coder # Join as coder
7
+ // kbot team join --role researcher # Join as researcher
8
+ // kbot team status # Show connected instances
9
+ //
10
+ // Architecture:
11
+ // - Local TCP server on localhost:7439 (configurable)
12
+ // - Newline-delimited JSON protocol (NDJSON)
13
+ // - Shared context store (any instance writes, all read)
14
+ // - Role-based task routing
15
+ //
16
+ // Workflow:
17
+ // Terminal 1: kbot team start → coordinator
18
+ // Terminal 2: kbot team join --role researcher
19
+ // Terminal 3: kbot team join --role coder
20
+ // Coordinator assigns "research RSC" → researcher gets the task
21
+ // Researcher shares findings → all instances receive context
22
+ // Coordinator assigns "implement RSC" → coder gets task + research context
23
+ import * as net from 'node:net';
24
+ import { randomUUID } from 'node:crypto';
25
+ import { registerTool } from './tools/index.js';
26
+ import { printInfo, printSuccess, printError, printWarn } from './ui.js';
27
+ // ── Constants ────────────────────────────────────────────────────────
28
+ const DEFAULT_PORT = 7439;
29
+ const HEARTBEAT_INTERVAL = 10_000;
30
+ const RECONNECT_DELAY = 2_000;
31
+ const MAX_RECONNECT_ATTEMPTS = 5;
32
+ // ── Server State ─────────────────────────────────────────────────────
33
+ let _server = null;
34
+ let _serverPort = DEFAULT_PORT;
35
+ const _instances = new Map();
36
+ const _sharedContext = new Map();
37
+ const _pendingTasks = new Map();
38
+ // ── Client State ─────────────────────────────────────────────────────
39
+ let _client = null;
40
+ let _reconnectAttempts = 0;
41
+ let _onTask = null;
42
+ let _onContext = null;
43
+ let _onBroadcast = null;
44
+ let _onStatus = null;
45
+ // ── Protocol ─────────────────────────────────────────────────────────
46
+ /** Encode a message as NDJSON (newline-delimited JSON) */
47
+ function encode(msg) {
48
+ return JSON.stringify(msg) + '\n';
49
+ }
50
+ /** Parse incoming data buffer into messages, handling partial lines */
51
+ function createMessageParser() {
52
+ let buffer = '';
53
+ return (chunk) => {
54
+ buffer += chunk.toString('utf-8');
55
+ const messages = [];
56
+ let newlineIdx;
57
+ while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
58
+ const line = buffer.slice(0, newlineIdx).trim();
59
+ buffer = buffer.slice(newlineIdx + 1);
60
+ if (!line)
61
+ continue;
62
+ try {
63
+ messages.push(JSON.parse(line));
64
+ }
65
+ catch {
66
+ // Malformed line — skip
67
+ }
68
+ }
69
+ return messages;
70
+ };
71
+ }
72
+ // ── Team Server ──────────────────────────────────────────────────────
73
+ /**
74
+ * Start the team coordination server.
75
+ * Maintains connected instances, shared context, and task routing.
76
+ */
77
+ export async function startTeamServer(options = {}) {
78
+ const port = options.port ?? DEFAULT_PORT;
79
+ _serverPort = port;
80
+ if (_server) {
81
+ printWarn('Team server already running');
82
+ return _server;
83
+ }
84
+ return new Promise((resolve, reject) => {
85
+ const server = net.createServer((socket) => {
86
+ const parser = createMessageParser();
87
+ let instanceId = '';
88
+ socket.on('data', (data) => {
89
+ const messages = parser(data);
90
+ for (const msg of messages) {
91
+ handleServerMessage(msg, socket);
92
+ if (msg.type === 'join') {
93
+ instanceId = msg.instanceId;
94
+ }
95
+ }
96
+ });
97
+ socket.on('error', (err) => {
98
+ if (instanceId) {
99
+ handleDisconnect(instanceId);
100
+ }
101
+ // ECONNRESET and EPIPE are expected on abrupt disconnects
102
+ if (err.code !== 'ECONNRESET' &&
103
+ err.code !== 'EPIPE') {
104
+ printError(`Socket error: ${err.message}`);
105
+ }
106
+ });
107
+ socket.on('close', () => {
108
+ if (instanceId) {
109
+ handleDisconnect(instanceId);
110
+ }
111
+ });
112
+ });
113
+ server.on('error', (err) => {
114
+ if (err.code === 'EADDRINUSE') {
115
+ printError(`Port ${port} is already in use. Another team server may be running.`);
116
+ printInfo(`Try: kbot team join --role coordinator --port ${port}`);
117
+ }
118
+ else {
119
+ printError(`Team server error: ${err.message}`);
120
+ }
121
+ reject(err);
122
+ });
123
+ server.listen(port, '127.0.0.1', () => {
124
+ _server = server;
125
+ printSuccess(`Team server running on localhost:${port}`);
126
+ printInfo('Waiting for instances to join...');
127
+ printInfo('');
128
+ printInfo('In other terminals, run:');
129
+ printInfo(` kbot team join --role researcher`);
130
+ printInfo(` kbot team join --role coder`);
131
+ printInfo(` kbot team join --role reviewer`);
132
+ resolve(server);
133
+ });
134
+ });
135
+ }
136
+ /** Handle an incoming message on the server side */
137
+ function handleServerMessage(msg, socket) {
138
+ switch (msg.type) {
139
+ case 'join': {
140
+ const instance = {
141
+ id: msg.instanceId,
142
+ role: msg.role,
143
+ status: 'idle',
144
+ socket,
145
+ joinedAt: new Date(),
146
+ };
147
+ _instances.set(msg.instanceId, instance);
148
+ printSuccess(`[+] ${msg.role} joined (${msg.instanceId.slice(0, 8)})`);
149
+ // Send current shared context to the new instance
150
+ for (const [key, value] of _sharedContext.entries()) {
151
+ const contextMsg = { type: 'context', key, value, from: 'server' };
152
+ safeSend(socket, contextMsg);
153
+ }
154
+ // Broadcast updated status to everyone
155
+ broadcastStatus();
156
+ break;
157
+ }
158
+ case 'leave': {
159
+ handleDisconnect(msg.instanceId);
160
+ break;
161
+ }
162
+ case 'context': {
163
+ // Store and broadcast to all other instances
164
+ _sharedContext.set(msg.key, msg.value);
165
+ broadcastToAll(msg, msg.from);
166
+ printInfo(`[ctx] ${msg.from.slice(0, 8)} shared "${msg.key}" (${msg.value.length} chars)`);
167
+ break;
168
+ }
169
+ case 'task': {
170
+ const task = {
171
+ taskId: msg.taskId,
172
+ task: msg.task,
173
+ assignedTo: msg.assignTo,
174
+ from: msg.from,
175
+ createdAt: new Date(),
176
+ status: 'pending',
177
+ };
178
+ _pendingTasks.set(msg.taskId, task);
179
+ // Route to the target role
180
+ const target = findInstanceByRole(msg.assignTo);
181
+ if (target) {
182
+ safeSend(target.socket, msg);
183
+ target.status = 'working';
184
+ task.status = 'in_progress';
185
+ printInfo(`[task] "${msg.task.slice(0, 60)}" → ${msg.assignTo} (${target.id.slice(0, 8)})`);
186
+ }
187
+ else {
188
+ // No instance with that role — notify sender
189
+ const errorMsg = {
190
+ type: 'broadcast',
191
+ message: `No instance with role "${msg.assignTo}" is connected. Available roles: ${getAvailableRoles().join(', ') || 'none'}`,
192
+ from: 'server',
193
+ };
194
+ safeSend(socket, errorMsg);
195
+ printWarn(`[task] No "${msg.assignTo}" instance connected`);
196
+ }
197
+ broadcastStatus();
198
+ break;
199
+ }
200
+ case 'result': {
201
+ const pendingTask = _pendingTasks.get(msg.taskId);
202
+ if (pendingTask) {
203
+ pendingTask.status = 'completed';
204
+ pendingTask.result = msg.result;
205
+ // Notify the task originator
206
+ const originator = _instances.get(pendingTask.from);
207
+ if (originator) {
208
+ safeSend(originator.socket, msg);
209
+ }
210
+ // Mark the completer as idle
211
+ const completer = _instances.get(msg.from);
212
+ if (completer) {
213
+ completer.status = 'idle';
214
+ }
215
+ printSuccess(`[done] Task ${msg.taskId.slice(0, 8)} completed by ${msg.from.slice(0, 8)}`);
216
+ }
217
+ broadcastStatus();
218
+ break;
219
+ }
220
+ case 'broadcast': {
221
+ broadcastToAll(msg, msg.from);
222
+ printInfo(`[msg] ${msg.from.slice(0, 8)}: ${msg.message.slice(0, 80)}`);
223
+ break;
224
+ }
225
+ default:
226
+ break;
227
+ }
228
+ }
229
+ /** Safely send a message to a socket, handling write errors */
230
+ function safeSend(socket, msg) {
231
+ try {
232
+ if (!socket.destroyed) {
233
+ socket.write(encode(msg));
234
+ return true;
235
+ }
236
+ }
237
+ catch {
238
+ // Socket may have been destroyed between the check and the write
239
+ }
240
+ return false;
241
+ }
242
+ /** Broadcast a message to all connected instances except the sender */
243
+ function broadcastToAll(msg, excludeId) {
244
+ for (const [id, instance] of _instances.entries()) {
245
+ if (id !== excludeId && instance.status !== 'disconnected') {
246
+ safeSend(instance.socket, msg);
247
+ }
248
+ }
249
+ }
250
+ /** Broadcast the current status (all instances) to everyone */
251
+ function broadcastStatus() {
252
+ const statusMsg = {
253
+ type: 'status',
254
+ instances: Array.from(_instances.values())
255
+ .filter(i => i.status !== 'disconnected')
256
+ .map(i => ({ id: i.id, role: i.role, status: i.status })),
257
+ };
258
+ for (const instance of _instances.values()) {
259
+ if (instance.status !== 'disconnected') {
260
+ safeSend(instance.socket, statusMsg);
261
+ }
262
+ }
263
+ }
264
+ /** Handle an instance disconnect */
265
+ function handleDisconnect(instanceId) {
266
+ const instance = _instances.get(instanceId);
267
+ if (instance && instance.status !== 'disconnected') {
268
+ instance.status = 'disconnected';
269
+ printWarn(`[-] ${instance.role} disconnected (${instanceId.slice(0, 8)})`);
270
+ // Reassign any pending tasks from this instance
271
+ for (const [taskId, task] of _pendingTasks.entries()) {
272
+ if (task.assignedTo === instance.role && task.status === 'in_progress') {
273
+ task.status = 'pending';
274
+ printWarn(`[task] Task ${taskId.slice(0, 8)} unassigned (${instance.role} disconnected)`);
275
+ }
276
+ }
277
+ _instances.delete(instanceId);
278
+ broadcastStatus();
279
+ }
280
+ }
281
+ /** Find a connected instance by role (picks the first idle one, or first available) */
282
+ function findInstanceByRole(role) {
283
+ let fallback;
284
+ for (const instance of _instances.values()) {
285
+ if (instance.role === role && instance.status !== 'disconnected') {
286
+ if (instance.status === 'idle')
287
+ return instance;
288
+ fallback = instance;
289
+ }
290
+ }
291
+ return fallback;
292
+ }
293
+ /** Get the list of currently connected roles */
294
+ function getAvailableRoles() {
295
+ const roles = new Set();
296
+ for (const instance of _instances.values()) {
297
+ if (instance.status !== 'disconnected') {
298
+ roles.add(instance.role);
299
+ }
300
+ }
301
+ return Array.from(roles);
302
+ }
303
+ /** Stop the team server and disconnect all clients */
304
+ export async function stopTeamServer() {
305
+ if (!_server)
306
+ return;
307
+ // Notify all instances
308
+ const leaveMsg = {
309
+ type: 'broadcast',
310
+ message: 'Team server shutting down.',
311
+ from: 'server',
312
+ };
313
+ broadcastToAll(leaveMsg);
314
+ // Close all sockets
315
+ for (const instance of _instances.values()) {
316
+ try {
317
+ instance.socket.destroy();
318
+ }
319
+ catch { /* already closed */ }
320
+ }
321
+ _instances.clear();
322
+ _sharedContext.clear();
323
+ _pendingTasks.clear();
324
+ return new Promise((resolve) => {
325
+ _server.close(() => {
326
+ _server = null;
327
+ printInfo('Team server stopped');
328
+ resolve();
329
+ });
330
+ });
331
+ }
332
+ // ── Team Client ──────────────────────────────────────────────────────
333
+ /**
334
+ * Join an existing team as a specific role.
335
+ * Returns methods to interact with the team.
336
+ */
337
+ export async function joinTeam(options) {
338
+ const port = options.port ?? DEFAULT_PORT;
339
+ const instanceId = options.instanceId ?? randomUUID();
340
+ const role = options.role;
341
+ _reconnectAttempts = 0;
342
+ const client = await connectToServer(port, instanceId, role);
343
+ return new TeamClient(client, instanceId, role, port);
344
+ }
345
+ /** Establish a TCP connection to the team server */
346
+ function connectToServer(port, instanceId, role) {
347
+ return new Promise((resolve, reject) => {
348
+ const socket = net.createConnection({ host: '127.0.0.1', port }, () => {
349
+ // Connected — send join message
350
+ const joinMsg = { type: 'join', role, instanceId };
351
+ socket.write(encode(joinMsg));
352
+ _client = socket;
353
+ _reconnectAttempts = 0;
354
+ printSuccess(`Joined team as "${role}" (${instanceId.slice(0, 8)})`);
355
+ resolve(socket);
356
+ });
357
+ const parser = createMessageParser();
358
+ socket.on('data', (data) => {
359
+ const messages = parser(data);
360
+ for (const msg of messages) {
361
+ handleClientMessage(msg);
362
+ }
363
+ });
364
+ socket.on('error', (err) => {
365
+ if (err.code === 'ECONNREFUSED') {
366
+ printError(`Cannot connect to team server on localhost:${port}`);
367
+ printInfo('Start one with: kbot team start');
368
+ reject(err);
369
+ }
370
+ else {
371
+ printError(`Team connection error: ${err.message}`);
372
+ attemptReconnect(port, instanceId, role);
373
+ }
374
+ });
375
+ socket.on('close', () => {
376
+ if (_client === socket) {
377
+ _client = null;
378
+ printWarn('Disconnected from team server');
379
+ attemptReconnect(port, instanceId, role);
380
+ }
381
+ });
382
+ });
383
+ }
384
+ /** Attempt to reconnect after a disconnection */
385
+ function attemptReconnect(port, instanceId, role) {
386
+ if (_reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
387
+ printError(`Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`);
388
+ return;
389
+ }
390
+ _reconnectAttempts++;
391
+ const delay = RECONNECT_DELAY * _reconnectAttempts;
392
+ printInfo(`Reconnecting in ${delay / 1000}s (attempt ${_reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
393
+ setTimeout(async () => {
394
+ try {
395
+ await connectToServer(port, instanceId, role);
396
+ }
397
+ catch {
398
+ // connectToServer already logs errors
399
+ }
400
+ }, delay);
401
+ }
402
+ /** Handle an incoming message on the client side */
403
+ function handleClientMessage(msg) {
404
+ switch (msg.type) {
405
+ case 'task': {
406
+ printInfo(`[task] Assigned: "${msg.task.slice(0, 80)}" (from ${msg.from.slice(0, 8)})`);
407
+ if (_onTask) {
408
+ _onTask(msg.task, msg.taskId, msg.from);
409
+ }
410
+ break;
411
+ }
412
+ case 'context': {
413
+ printInfo(`[ctx] ${msg.from.slice(0, 8)} shared "${msg.key}" (${msg.value.length} chars)`);
414
+ // Store locally
415
+ _sharedContext.set(msg.key, msg.value);
416
+ if (_onContext) {
417
+ _onContext(msg.key, msg.value, msg.from);
418
+ }
419
+ break;
420
+ }
421
+ case 'broadcast': {
422
+ printInfo(`[team] ${msg.from.slice(0, 8)}: ${msg.message.slice(0, 200)}`);
423
+ if (_onBroadcast) {
424
+ _onBroadcast(msg.message, msg.from);
425
+ }
426
+ break;
427
+ }
428
+ case 'result': {
429
+ printSuccess(`[result] Task ${msg.taskId.slice(0, 8)} completed: ${msg.result.slice(0, 100)}`);
430
+ break;
431
+ }
432
+ case 'status': {
433
+ if (_onStatus) {
434
+ _onStatus(msg.instances);
435
+ }
436
+ break;
437
+ }
438
+ default:
439
+ break;
440
+ }
441
+ }
442
+ // ── Team Client Class ────────────────────────────────────────────────
443
+ export class TeamClient {
444
+ socket;
445
+ instanceId;
446
+ role;
447
+ port;
448
+ constructor(socket, instanceId, role, port) {
449
+ this.socket = socket;
450
+ this.instanceId = instanceId;
451
+ this.role = role;
452
+ this.port = port;
453
+ }
454
+ /** Share a context value with the entire team */
455
+ shareContext(key, value) {
456
+ const msg = {
457
+ type: 'context',
458
+ key,
459
+ value,
460
+ from: this.instanceId,
461
+ };
462
+ this.send(msg);
463
+ }
464
+ /** Request a task be assigned to a specific role */
465
+ requestTask(role, task) {
466
+ const taskId = randomUUID();
467
+ const msg = {
468
+ type: 'task',
469
+ task,
470
+ assignTo: role,
471
+ from: this.instanceId,
472
+ taskId,
473
+ };
474
+ this.send(msg);
475
+ return taskId;
476
+ }
477
+ /** Submit a result for a completed task */
478
+ submitResult(taskId, result) {
479
+ const msg = {
480
+ type: 'result',
481
+ taskId,
482
+ result,
483
+ from: this.instanceId,
484
+ };
485
+ this.send(msg);
486
+ }
487
+ /** Broadcast a message to all team members */
488
+ broadcastMessage(message) {
489
+ const msg = {
490
+ type: 'broadcast',
491
+ message,
492
+ from: this.instanceId,
493
+ };
494
+ this.send(msg);
495
+ }
496
+ /** Get locally cached shared context */
497
+ getContext(key) {
498
+ return _sharedContext.get(key);
499
+ }
500
+ /** Get all shared context entries */
501
+ getAllContext() {
502
+ return new Map(_sharedContext);
503
+ }
504
+ /** Register handler for incoming tasks */
505
+ onTask(handler) {
506
+ _onTask = handler;
507
+ }
508
+ /** Register handler for incoming context updates */
509
+ onContext(handler) {
510
+ _onContext = handler;
511
+ }
512
+ /** Register handler for broadcast messages */
513
+ onBroadcast(handler) {
514
+ _onBroadcast = handler;
515
+ }
516
+ /** Register handler for status updates */
517
+ onStatus(handler) {
518
+ _onStatus = handler;
519
+ }
520
+ /** Leave the team gracefully */
521
+ leave() {
522
+ const msg = { type: 'leave', instanceId: this.instanceId };
523
+ this.send(msg);
524
+ setTimeout(() => {
525
+ try {
526
+ this.socket.destroy();
527
+ }
528
+ catch { /* already closed */ }
529
+ _client = null;
530
+ }, 100);
531
+ printInfo(`Left team (${this.role})`);
532
+ }
533
+ /** Check if connected */
534
+ get connected() {
535
+ return !this.socket.destroyed;
536
+ }
537
+ send(msg) {
538
+ if (this.socket.destroyed) {
539
+ printWarn('Not connected to team server');
540
+ return;
541
+ }
542
+ try {
543
+ this.socket.write(encode(msg));
544
+ }
545
+ catch (err) {
546
+ printError(`Failed to send: ${err instanceof Error ? err.message : String(err)}`);
547
+ }
548
+ }
549
+ }
550
+ // ── Module-level Client Reference ────────────────────────────────────
551
+ // Used by the tools to access the active client without threading it through
552
+ let _activeClient = null;
553
+ export function getActiveClient() {
554
+ return _activeClient;
555
+ }
556
+ export function setActiveClient(client) {
557
+ _activeClient = client;
558
+ }
559
+ // ── Tool Registration ────────────────────────────────────────────────
560
+ export function registerTeamTools() {
561
+ registerTool({
562
+ name: 'team_start',
563
+ description: 'Start the team coordination server. Other kbot instances can then join. Optionally join as coordinator.',
564
+ parameters: {
565
+ port: { type: 'number', description: `TCP port for the team server (default: ${DEFAULT_PORT})` },
566
+ join_as: { type: 'string', description: 'Also join the team with this role (e.g., "coordinator")' },
567
+ },
568
+ tier: 'free',
569
+ async execute(args) {
570
+ const port = args.port ? Number(args.port) : DEFAULT_PORT;
571
+ try {
572
+ await startTeamServer({ port });
573
+ // Optionally join the server we just started
574
+ if (args.join_as) {
575
+ const role = String(args.join_as);
576
+ const client = await joinTeam({ port, role });
577
+ setActiveClient(client);
578
+ return `Team server started on localhost:${port}. Joined as "${role}".`;
579
+ }
580
+ return `Team server started on localhost:${port}. Run 'kbot team join --role <role>' in other terminals.`;
581
+ }
582
+ catch (err) {
583
+ return `Failed to start team server: ${err instanceof Error ? err.message : String(err)}`;
584
+ }
585
+ },
586
+ });
587
+ registerTool({
588
+ name: 'team_join',
589
+ description: 'Join an existing team with a specific role (researcher, coder, reviewer, etc.).',
590
+ parameters: {
591
+ role: { type: 'string', description: 'Your role in the team (researcher, coder, reviewer, coordinator, etc.)', required: true },
592
+ port: { type: 'number', description: `Team server port (default: ${DEFAULT_PORT})` },
593
+ },
594
+ tier: 'free',
595
+ async execute(args) {
596
+ const role = String(args.role);
597
+ const port = args.port ? Number(args.port) : DEFAULT_PORT;
598
+ try {
599
+ const client = await joinTeam({ port, role });
600
+ setActiveClient(client);
601
+ return `Joined team as "${role}" on localhost:${port}. Instance ID: ${client.instanceId.slice(0, 8)}`;
602
+ }
603
+ catch (err) {
604
+ return `Failed to join team: ${err instanceof Error ? err.message : String(err)}`;
605
+ }
606
+ },
607
+ });
608
+ registerTool({
609
+ name: 'team_status',
610
+ description: 'Show all connected team instances and their current status.',
611
+ parameters: {
612
+ port: { type: 'number', description: `Team server port (default: ${DEFAULT_PORT})` },
613
+ },
614
+ tier: 'free',
615
+ async execute() {
616
+ // If we're the server, read directly from server state
617
+ if (_server) {
618
+ const instances = Array.from(_instances.values())
619
+ .filter(i => i.status !== 'disconnected');
620
+ if (instances.length === 0) {
621
+ return 'Team server running, but no instances connected.';
622
+ }
623
+ const lines = instances.map(i => {
624
+ const uptime = Math.round((Date.now() - i.joinedAt.getTime()) / 1000);
625
+ return ` ${i.role.padEnd(15)} ${i.status.padEnd(10)} ${i.id.slice(0, 8)} (${uptime}s)`;
626
+ });
627
+ const contextKeys = Array.from(_sharedContext.keys());
628
+ const tasksSummary = Array.from(_pendingTasks.values());
629
+ const pending = tasksSummary.filter(t => t.status === 'pending').length;
630
+ const inProgress = tasksSummary.filter(t => t.status === 'in_progress').length;
631
+ const completed = tasksSummary.filter(t => t.status === 'completed').length;
632
+ return [
633
+ `Team Server — localhost:${_serverPort}`,
634
+ ``,
635
+ `Instances (${instances.length}):`,
636
+ ` ${'Role'.padEnd(15)} ${'Status'.padEnd(10)} ID`,
637
+ ` ${'─'.repeat(15)} ${'─'.repeat(10)} ${'─'.repeat(8)}`,
638
+ ...lines,
639
+ ``,
640
+ `Shared Context: ${contextKeys.length} entries${contextKeys.length > 0 ? ' (' + contextKeys.join(', ') + ')' : ''}`,
641
+ `Tasks: ${pending} pending, ${inProgress} in progress, ${completed} completed`,
642
+ ].join('\n');
643
+ }
644
+ // If we're a client, report what we know
645
+ if (_activeClient) {
646
+ const context = _activeClient.getAllContext();
647
+ return [
648
+ `Connected as "${_activeClient.role}" (${_activeClient.instanceId.slice(0, 8)})`,
649
+ `Connected: ${_activeClient.connected ? 'yes' : 'no'}`,
650
+ `Shared context: ${context.size} entries${context.size > 0 ? ' (' + Array.from(context.keys()).join(', ') + ')' : ''}`,
651
+ ].join('\n');
652
+ }
653
+ return 'Not connected to any team. Run team_start or team_join first.';
654
+ },
655
+ });
656
+ registerTool({
657
+ name: 'team_share',
658
+ description: 'Share context with the team (e.g., research findings, code snippets, decisions). All team members receive it.',
659
+ parameters: {
660
+ key: { type: 'string', description: 'Context key (e.g., "research_findings", "api_design", "review_notes")', required: true },
661
+ value: { type: 'string', description: 'Context value — the actual content to share', required: true },
662
+ },
663
+ tier: 'free',
664
+ async execute(args) {
665
+ const key = String(args.key);
666
+ const value = String(args.value);
667
+ if (_activeClient) {
668
+ _activeClient.shareContext(key, value);
669
+ return `Shared "${key}" with team (${value.length} chars)`;
670
+ }
671
+ // If we're only the server (no client), store directly and broadcast
672
+ if (_server) {
673
+ _sharedContext.set(key, value);
674
+ const msg = { type: 'context', key, value, from: 'server' };
675
+ broadcastToAll(msg);
676
+ return `Shared "${key}" with team from server (${value.length} chars)`;
677
+ }
678
+ return 'Not connected to any team. Run team_start or team_join first.';
679
+ },
680
+ });
681
+ registerTool({
682
+ name: 'team_assign',
683
+ description: 'Assign a task to a specific role in the team. The instance with that role will receive the task.',
684
+ parameters: {
685
+ role: { type: 'string', description: 'Target role (researcher, coder, reviewer, etc.)', required: true },
686
+ task: { type: 'string', description: 'Task description — what the target should do', required: true },
687
+ },
688
+ tier: 'free',
689
+ async execute(args) {
690
+ const role = String(args.role);
691
+ const task = String(args.task);
692
+ if (_activeClient) {
693
+ const taskId = _activeClient.requestTask(role, task);
694
+ return `Task assigned to "${role}": ${task.slice(0, 100)}${task.length > 100 ? '...' : ''}\nTask ID: ${taskId.slice(0, 8)}`;
695
+ }
696
+ // Server can assign tasks directly
697
+ if (_server) {
698
+ const taskId = randomUUID();
699
+ const target = findInstanceByRole(role);
700
+ if (!target) {
701
+ const available = getAvailableRoles();
702
+ return `No instance with role "${role}" is connected. Available: ${available.join(', ') || 'none'}`;
703
+ }
704
+ const msg = { type: 'task', task, assignTo: role, from: 'server', taskId };
705
+ safeSend(target.socket, msg);
706
+ target.status = 'working';
707
+ const pendingTask = {
708
+ taskId,
709
+ task,
710
+ assignedTo: role,
711
+ from: 'server',
712
+ createdAt: new Date(),
713
+ status: 'in_progress',
714
+ };
715
+ _pendingTasks.set(taskId, pendingTask);
716
+ broadcastStatus();
717
+ return `Task assigned to "${role}" (${target.id.slice(0, 8)}): ${task.slice(0, 100)}${task.length > 100 ? '...' : ''}\nTask ID: ${taskId.slice(0, 8)}`;
718
+ }
719
+ return 'Not connected to any team. Run team_start or team_join first.';
720
+ },
721
+ });
722
+ registerTool({
723
+ name: 'team_broadcast',
724
+ description: 'Send a message to all team members. Use for announcements, status updates, or coordination.',
725
+ parameters: {
726
+ message: { type: 'string', description: 'Message to broadcast to all team members', required: true },
727
+ },
728
+ tier: 'free',
729
+ async execute(args) {
730
+ const message = String(args.message);
731
+ if (_activeClient) {
732
+ _activeClient.broadcastMessage(message);
733
+ return `Broadcast sent to team: ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`;
734
+ }
735
+ if (_server) {
736
+ const msg = { type: 'broadcast', message, from: 'server' };
737
+ broadcastToAll(msg);
738
+ return `Broadcast sent from server: ${message.slice(0, 100)}${message.length > 100 ? '...' : ''}`;
739
+ }
740
+ return 'Not connected to any team. Run team_start or team_join first.';
741
+ },
742
+ });
743
+ registerTool({
744
+ name: 'team_context',
745
+ description: 'Read shared context from the team. Returns a specific key or all shared context.',
746
+ parameters: {
747
+ key: { type: 'string', description: 'Context key to read. Omit to list all keys.' },
748
+ },
749
+ tier: 'free',
750
+ async execute(args) {
751
+ if (!_activeClient && !_server) {
752
+ return 'Not connected to any team. Run team_start or team_join first.';
753
+ }
754
+ if (args.key) {
755
+ const key = String(args.key);
756
+ const value = _sharedContext.get(key);
757
+ if (value) {
758
+ return `[${key}]\n${value}`;
759
+ }
760
+ return `No shared context for key "${key}". Available keys: ${Array.from(_sharedContext.keys()).join(', ') || 'none'}`;
761
+ }
762
+ // List all context
763
+ if (_sharedContext.size === 0) {
764
+ return 'No shared context yet. Team members can share context with team_share.';
765
+ }
766
+ const entries = Array.from(_sharedContext.entries()).map(([k, v]) => {
767
+ const preview = v.length > 120 ? v.slice(0, 120) + '...' : v;
768
+ return `[${k}] (${v.length} chars)\n ${preview}`;
769
+ });
770
+ return `Shared Context (${_sharedContext.size} entries):\n\n${entries.join('\n\n')}`;
771
+ },
772
+ });
773
+ registerTool({
774
+ name: 'team_result',
775
+ description: 'Submit a result for a task that was assigned to you.',
776
+ parameters: {
777
+ task_id: { type: 'string', description: 'Task ID (shown when task was assigned)', required: true },
778
+ result: { type: 'string', description: 'Task result — what you accomplished', required: true },
779
+ },
780
+ tier: 'free',
781
+ async execute(args) {
782
+ const taskId = String(args.task_id);
783
+ const result = String(args.result);
784
+ if (_activeClient) {
785
+ _activeClient.submitResult(taskId, result);
786
+ return `Result submitted for task ${taskId.slice(0, 8)}`;
787
+ }
788
+ return 'Not connected to any team. Run team_join first.';
789
+ },
790
+ });
791
+ registerTool({
792
+ name: 'team_stop',
793
+ description: 'Stop the team server and disconnect all instances.',
794
+ parameters: {},
795
+ tier: 'free',
796
+ async execute() {
797
+ if (_activeClient) {
798
+ _activeClient.leave();
799
+ setActiveClient(null);
800
+ }
801
+ if (_server) {
802
+ await stopTeamServer();
803
+ return 'Team server stopped. All instances disconnected.';
804
+ }
805
+ return 'No team server running on this instance.';
806
+ },
807
+ });
808
+ }
809
+ // ── CLI Integration ──────────────────────────────────────────────────
810
+ /**
811
+ * Register the `kbot team` subcommand with a Commander program.
812
+ * Called from cli.ts.
813
+ */
814
+ export function registerTeamCommand(program) {
815
+ const teamCmd = program
816
+ .command('team')
817
+ .description('Team mode — coordinate multiple kbot instances');
818
+ teamCmd
819
+ .command('start')
820
+ .description('Start the team coordination server')
821
+ .option('--port <port>', 'TCP port', String(DEFAULT_PORT))
822
+ .option('--role <role>', 'Also join as this role (default: coordinator)')
823
+ .action(async (opts) => {
824
+ const port = parseInt(opts.port, 10);
825
+ const role = opts.role ?? 'coordinator';
826
+ try {
827
+ await startTeamServer({ port });
828
+ const client = await joinTeam({ port, role });
829
+ setActiveClient(client);
830
+ // Keep the process alive and handle shutdown
831
+ const shutdown = async () => {
832
+ printInfo('\nShutting down team...');
833
+ client.leave();
834
+ await stopTeamServer();
835
+ process.exit(0);
836
+ };
837
+ process.on('SIGINT', shutdown);
838
+ process.on('SIGTERM', shutdown);
839
+ // Heartbeat — periodically log status
840
+ setInterval(() => {
841
+ const count = _instances.size;
842
+ if (count > 0) {
843
+ const roles = getAvailableRoles();
844
+ printInfo(`[heartbeat] ${count} instance(s): ${roles.join(', ')}`);
845
+ }
846
+ }, HEARTBEAT_INTERVAL);
847
+ // Keep alive
848
+ await new Promise(() => { });
849
+ }
850
+ catch {
851
+ process.exit(1);
852
+ }
853
+ });
854
+ teamCmd
855
+ .command('join')
856
+ .description('Join an existing team with a role')
857
+ .requiredOption('--role <role>', 'Your role (researcher, coder, reviewer, etc.)')
858
+ .option('--port <port>', 'Team server port', String(DEFAULT_PORT))
859
+ .action(async (opts) => {
860
+ const port = parseInt(opts.port, 10);
861
+ try {
862
+ const client = await joinTeam({ port, role: opts.role });
863
+ setActiveClient(client);
864
+ // Handle shutdown
865
+ const shutdown = () => {
866
+ printInfo('\nLeaving team...');
867
+ client.leave();
868
+ process.exit(0);
869
+ };
870
+ process.on('SIGINT', shutdown);
871
+ process.on('SIGTERM', shutdown);
872
+ // Keep alive
873
+ await new Promise(() => { });
874
+ }
875
+ catch {
876
+ process.exit(1);
877
+ }
878
+ });
879
+ teamCmd
880
+ .command('status')
881
+ .description('Show connected team instances')
882
+ .option('--port <port>', 'Team server port', String(DEFAULT_PORT))
883
+ .action(async (opts) => {
884
+ const port = parseInt(opts.port, 10);
885
+ // Connect briefly to get status, then disconnect
886
+ try {
887
+ const client = await joinTeam({ port, role: '_status_probe', instanceId: `probe-${randomUUID()}` });
888
+ // Wait a moment for the status broadcast to arrive
889
+ await new Promise((resolve) => {
890
+ const timeout = setTimeout(() => {
891
+ resolve();
892
+ }, 1000);
893
+ client.onStatus((instances) => {
894
+ clearTimeout(timeout);
895
+ console.log('');
896
+ console.log(`Team — localhost:${port}`);
897
+ console.log(`${'Role'.padEnd(15)} ${'Status'.padEnd(10)} ID`);
898
+ console.log(`${'─'.repeat(15)} ${'─'.repeat(10)} ${'─'.repeat(8)}`);
899
+ for (const inst of instances) {
900
+ if (inst.role === '_status_probe')
901
+ continue;
902
+ console.log(`${inst.role.padEnd(15)} ${inst.status.padEnd(10)} ${inst.id.slice(0, 8)}`);
903
+ }
904
+ console.log(`\n${instances.filter(i => i.role !== '_status_probe').length} instance(s) connected`);
905
+ resolve();
906
+ });
907
+ });
908
+ client.leave();
909
+ process.exit(0);
910
+ }
911
+ catch {
912
+ printError('Cannot connect to team server. Is one running?');
913
+ process.exit(1);
914
+ }
915
+ });
916
+ }
917
+ //# sourceMappingURL=team.js.map