agent-coord-mcp 0.2.1 → 0.3.1
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/README.md +83 -8
- package/dist/server.js +7 -2
- package/dist/server.js.map +1 -1
- package/dist/store.js +19 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +345 -42
- package/dist/tools.js.map +1 -1
- package/hooks/tmux-pusher.mjs +273 -0
- package/package.json +2 -1
- package/scripts/spawn-agent.sh +91 -0
- package/scripts/stop-agent.sh +43 -0
- package/src/server.ts +46 -1
- package/src/store.ts +22 -1
- package/src/tools.ts +398 -41
package/src/tools.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { watch } from "node:fs";
|
|
2
|
+
import { existsSync, openSync, watch } from "node:fs";
|
|
3
|
+
import { promises as fsp } from "node:fs";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
import { z } from "zod";
|
|
4
7
|
import path from "node:path";
|
|
5
8
|
import {
|
|
@@ -15,9 +18,13 @@ import {
|
|
|
15
18
|
inboxFile,
|
|
16
19
|
listCursorFiles,
|
|
17
20
|
listInboxFiles,
|
|
21
|
+
listTransportFiles,
|
|
22
|
+
logFile,
|
|
23
|
+
pidFile,
|
|
18
24
|
readJson,
|
|
19
25
|
readJsonl,
|
|
20
26
|
rewriteJsonl,
|
|
27
|
+
transportFile,
|
|
21
28
|
updateJson,
|
|
22
29
|
} from "./store.js";
|
|
23
30
|
|
|
@@ -27,6 +34,15 @@ type AgentEntry = {
|
|
|
27
34
|
role?: string;
|
|
28
35
|
registeredAt: number;
|
|
29
36
|
lastHeartbeat: number;
|
|
37
|
+
capabilities?: string[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type TransportMarker = {
|
|
41
|
+
agentId: string;
|
|
42
|
+
transport: string;
|
|
43
|
+
pid: number;
|
|
44
|
+
tmuxTarget?: string;
|
|
45
|
+
since: number;
|
|
30
46
|
};
|
|
31
47
|
|
|
32
48
|
type AgentRegistry = Record<string, AgentEntry>;
|
|
@@ -82,6 +98,26 @@ export async function registerTool(args: { agentId: string; project?: string; ro
|
|
|
82
98
|
return { ok: true, agent: reg[args.agentId] };
|
|
83
99
|
}
|
|
84
100
|
|
|
101
|
+
// ---------- unregister ----------
|
|
102
|
+
|
|
103
|
+
export const unregisterSchema = { agentId: z.string().min(1) };
|
|
104
|
+
|
|
105
|
+
export async function unregisterTool(args: { agentId: string }) {
|
|
106
|
+
// If a transport is attached, take it down first so the pusher doesn't keep
|
|
107
|
+
// re-publishing the marker after we drop the registry entry.
|
|
108
|
+
const detach = await detachAgentTool(args);
|
|
109
|
+
|
|
110
|
+
let existed = false;
|
|
111
|
+
await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
112
|
+
if (current[args.agentId]) {
|
|
113
|
+
existed = true;
|
|
114
|
+
delete current[args.agentId];
|
|
115
|
+
}
|
|
116
|
+
return current;
|
|
117
|
+
});
|
|
118
|
+
return { ok: true, removed: existed, detach };
|
|
119
|
+
}
|
|
120
|
+
|
|
85
121
|
// ---------- heartbeat ----------
|
|
86
122
|
|
|
87
123
|
export const heartbeatSchema = { agentId: z.string().min(1) };
|
|
@@ -107,8 +143,18 @@ export const listAgentsSchema = {} as const;
|
|
|
107
143
|
export async function listAgentsTool() {
|
|
108
144
|
const now = Date.now();
|
|
109
145
|
const evicted: string[] = [];
|
|
146
|
+
|
|
147
|
+
// Load live transport markers first so we can refresh heartbeats for agents
|
|
148
|
+
// whose pusher (or other transport daemon) is alive — the live process IS
|
|
149
|
+
// the heartbeat, no separate ping needed.
|
|
150
|
+
const liveTransports = await loadLiveTransports();
|
|
151
|
+
|
|
110
152
|
const reg = await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
111
153
|
for (const [id, entry] of Object.entries(current)) {
|
|
154
|
+
if (liveTransports.has(id)) {
|
|
155
|
+
entry.lastHeartbeat = now;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
112
158
|
if (now - entry.lastHeartbeat > EVICT_MS) {
|
|
113
159
|
evicted.push(id);
|
|
114
160
|
delete current[id];
|
|
@@ -116,14 +162,53 @@ export async function listAgentsTool() {
|
|
|
116
162
|
}
|
|
117
163
|
return current;
|
|
118
164
|
});
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
165
|
+
|
|
166
|
+
const agents = Object.values(reg).map((a) => {
|
|
167
|
+
const transport = liveTransports.get(a.agentId);
|
|
168
|
+
const merged = [...(a.capabilities ?? [])];
|
|
169
|
+
if (transport && !merged.includes(transport.transport)) merged.push(transport.transport);
|
|
170
|
+
return {
|
|
171
|
+
...a,
|
|
172
|
+
online: transport ? true : now - a.lastHeartbeat < STALE_MS,
|
|
173
|
+
secondsSinceHeartbeat: Math.floor((now - a.lastHeartbeat) / 1000),
|
|
174
|
+
capabilities: merged.length > 0 ? merged : undefined,
|
|
175
|
+
transport: transport
|
|
176
|
+
? { kind: transport.transport, tmuxTarget: transport.tmuxTarget, pid: transport.pid }
|
|
177
|
+
: undefined,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
124
180
|
return { agents, evicted };
|
|
125
181
|
}
|
|
126
182
|
|
|
183
|
+
async function loadLiveTransports(): Promise<Map<string, TransportMarker>> {
|
|
184
|
+
const out = new Map<string, TransportMarker>();
|
|
185
|
+
for (const fname of await listTransportFiles()) {
|
|
186
|
+
const file = path.join(path.dirname(transportFile("x")), fname);
|
|
187
|
+
const marker = await readJson<TransportMarker | null>(file, null);
|
|
188
|
+
if (!marker) {
|
|
189
|
+
await deleteFile(file);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (!isPidAlive(marker.pid)) {
|
|
193
|
+
await deleteFile(file);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
out.set(marker.agentId, marker);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isPidAlive(pid: number): boolean {
|
|
202
|
+
if (!pid || pid <= 0) return false;
|
|
203
|
+
try {
|
|
204
|
+
process.kill(pid, 0);
|
|
205
|
+
return true;
|
|
206
|
+
} catch (err) {
|
|
207
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
208
|
+
return code === "EPERM"; // EPERM = exists but not ours; ESRCH = gone
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
127
212
|
// ---------- send_message ----------
|
|
128
213
|
|
|
129
214
|
export const sendMessageSchema = {
|
|
@@ -195,7 +280,19 @@ export async function readMessagesTool(args: {
|
|
|
195
280
|
});
|
|
196
281
|
}
|
|
197
282
|
|
|
198
|
-
|
|
283
|
+
// Drop the agent's own posts on shared channels — reading your own broadcast
|
|
284
|
+
// back is never useful and confuses turn-based agents into self-replies.
|
|
285
|
+
// Cursor has already advanced past them, so they won't reappear.
|
|
286
|
+
const visible =
|
|
287
|
+
args.source === "room" || args.source === "status"
|
|
288
|
+
? limited.filter((e) => entryAuthor(e) !== args.agentId)
|
|
289
|
+
: limited;
|
|
290
|
+
|
|
291
|
+
return { messages: visible, totalNew, returned: visible.length };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function entryAuthor(e: Message | StatusEntry): string | undefined {
|
|
295
|
+
return "from" in e ? e.from : e.agentId;
|
|
199
296
|
}
|
|
200
297
|
|
|
201
298
|
// ---------- post_status ----------
|
|
@@ -231,46 +328,57 @@ export async function waitForMessageTool(args: {
|
|
|
231
328
|
source: "inbox" | "room" | "status";
|
|
232
329
|
timeoutMs?: number;
|
|
233
330
|
}) {
|
|
234
|
-
const
|
|
331
|
+
const totalTimeout = Math.min(args.timeoutMs ?? 30_000, MAX_WAIT_MS);
|
|
235
332
|
const file = sourceFile(args.source, args.agentId);
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
333
|
+
const deadline = Date.now() + totalTimeout;
|
|
334
|
+
|
|
335
|
+
// Loop so that file growth caused only by the agent's own self-posts (which
|
|
336
|
+
// readMessagesTool now filters out for room/status) doesn't return an empty
|
|
337
|
+
// result — keep waiting until we have something to deliver or time out.
|
|
338
|
+
while (Date.now() < deadline) {
|
|
339
|
+
const startSize = await fileSize(file);
|
|
340
|
+
const remaining = deadline - Date.now();
|
|
341
|
+
if (remaining <= 0) break;
|
|
342
|
+
|
|
343
|
+
const changed = await new Promise<boolean>((resolve) => {
|
|
344
|
+
let settled = false;
|
|
345
|
+
const finish = (v: boolean) => {
|
|
346
|
+
if (settled) return;
|
|
347
|
+
settled = true;
|
|
348
|
+
clearInterval(poll);
|
|
349
|
+
try {
|
|
350
|
+
watcher?.close();
|
|
351
|
+
} catch {
|
|
352
|
+
// ignore
|
|
353
|
+
}
|
|
354
|
+
clearTimeout(t);
|
|
355
|
+
resolve(v);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const check = async () => {
|
|
359
|
+
const sz = await fileSize(file);
|
|
360
|
+
if (sz > startSize) finish(true);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
let watcher: ReturnType<typeof watch> | undefined;
|
|
244
364
|
try {
|
|
245
|
-
watcher
|
|
365
|
+
watcher = watch(file, () => {
|
|
366
|
+
void check();
|
|
367
|
+
});
|
|
246
368
|
} catch {
|
|
247
|
-
//
|
|
369
|
+
// file may not exist; polling will handle
|
|
248
370
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
const check = async () => {
|
|
254
|
-
const sz = await fileSize(file);
|
|
255
|
-
if (sz > startSize) finish(true);
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
let watcher: ReturnType<typeof watch> | undefined;
|
|
259
|
-
try {
|
|
260
|
-
watcher = watch(file, () => {
|
|
261
|
-
void check();
|
|
262
|
-
});
|
|
263
|
-
} catch {
|
|
264
|
-
// file may not exist; polling will handle
|
|
265
|
-
}
|
|
266
|
-
const poll = setInterval(() => void check(), 500);
|
|
267
|
-
const t = setTimeout(() => finish(false), timeout);
|
|
268
|
-
});
|
|
371
|
+
const poll = setInterval(() => void check(), 500);
|
|
372
|
+
const t = setTimeout(() => finish(false), remaining);
|
|
373
|
+
});
|
|
269
374
|
|
|
270
|
-
|
|
271
|
-
|
|
375
|
+
if (!changed) break;
|
|
376
|
+
const result = await readMessagesTool({ agentId: args.agentId, source: args.source });
|
|
377
|
+
if (result.returned > 0) return result;
|
|
378
|
+
// otherwise, only self-posts arrived; keep waiting on the remaining budget
|
|
272
379
|
}
|
|
273
|
-
|
|
380
|
+
|
|
381
|
+
return { ok: false, timedOut: true };
|
|
274
382
|
}
|
|
275
383
|
|
|
276
384
|
// ---------- prune ----------
|
|
@@ -384,6 +492,255 @@ export async function pruneTool(args: {
|
|
|
384
492
|
};
|
|
385
493
|
}
|
|
386
494
|
|
|
495
|
+
// ---------- attach_agent / detach_agent (tmux push transport) ----------
|
|
496
|
+
|
|
497
|
+
export const attachAgentSchema = {
|
|
498
|
+
agentId: z.string().min(1),
|
|
499
|
+
tmuxTarget: z.string().optional(),
|
|
500
|
+
includeRoom: z.boolean().optional(),
|
|
501
|
+
allowlist: z.array(z.string()).optional(),
|
|
502
|
+
debounceMs: z.number().int().positive().max(60_000).optional(),
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
export async function attachAgentTool(args: {
|
|
506
|
+
agentId: string;
|
|
507
|
+
tmuxTarget?: string;
|
|
508
|
+
includeRoom?: boolean;
|
|
509
|
+
allowlist?: string[];
|
|
510
|
+
debounceMs?: number;
|
|
511
|
+
}) {
|
|
512
|
+
// Resolve target: explicit arg > MCP server's own TMUX_PANE env.
|
|
513
|
+
const target = args.tmuxTarget ?? process.env.TMUX_PANE;
|
|
514
|
+
if (!target) {
|
|
515
|
+
return {
|
|
516
|
+
ok: false,
|
|
517
|
+
error:
|
|
518
|
+
"tmuxTarget not provided and the MCP server is not running inside tmux (no $TMUX_PANE). Pass tmuxTarget explicitly (e.g. '%42' or 'session:window.pane').",
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Validate target exists.
|
|
523
|
+
const probe = spawnSync("tmux", ["display-message", "-p", "-t", target, "ok"]);
|
|
524
|
+
if (probe.status !== 0) {
|
|
525
|
+
return {
|
|
526
|
+
ok: false,
|
|
527
|
+
error: `tmux target '${target}' not found: ${(probe.stderr ?? "").toString().trim()}`,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// If something's already attached, refuse rather than spawn a second pusher.
|
|
532
|
+
const existing = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
|
|
533
|
+
if (existing && isPidAlive(existing.pid)) {
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
error: `agent '${args.agentId}' already has a live ${existing.transport} attached (pid ${existing.pid}). Call detach_agent first.`,
|
|
537
|
+
existing,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// Clean up dead marker, if any.
|
|
541
|
+
if (existing) await deleteFile(transportFile(args.agentId));
|
|
542
|
+
|
|
543
|
+
const pusher = resolvePusherPath();
|
|
544
|
+
if (!existsSync(pusher)) {
|
|
545
|
+
return { ok: false, error: `tmux-pusher not found at ${pusher}` };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Detached spawn so the pusher outlives this MCP request/process.
|
|
549
|
+
const log = logFile(args.agentId, "pusher");
|
|
550
|
+
await fsp.mkdir(path.dirname(log), { recursive: true });
|
|
551
|
+
await fsp.mkdir(path.dirname(pidFile(args.agentId, "pusher")), { recursive: true });
|
|
552
|
+
await fsp.mkdir(path.dirname(transportFile(args.agentId)), { recursive: true });
|
|
553
|
+
const out = openSync(log, "a");
|
|
554
|
+
const err = openSync(log, "a");
|
|
555
|
+
const child = spawn("node", [pusher], {
|
|
556
|
+
detached: true,
|
|
557
|
+
stdio: ["ignore", out, err],
|
|
558
|
+
env: {
|
|
559
|
+
...process.env,
|
|
560
|
+
AGENT_COORD_ID: args.agentId,
|
|
561
|
+
AGENT_COORD_TMUX_TARGET: target,
|
|
562
|
+
...(args.includeRoom ? { AGENT_COORD_INCLUDE_ROOM: "1" } : {}),
|
|
563
|
+
...(args.allowlist && args.allowlist.length > 0
|
|
564
|
+
? { AGENT_COORD_ALLOWLIST: args.allowlist.join(",") }
|
|
565
|
+
: {}),
|
|
566
|
+
...(args.debounceMs ? { AGENT_COORD_DEBOUNCE_MS: String(args.debounceMs) } : {}),
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
child.unref();
|
|
570
|
+
const pid = child.pid;
|
|
571
|
+
if (!pid) return { ok: false, error: "spawn returned no pid" };
|
|
572
|
+
|
|
573
|
+
// Write pid file (for scripts) and transport marker (for list_agents).
|
|
574
|
+
await fsp.writeFile(pidFile(args.agentId, "pusher"), String(pid), "utf8");
|
|
575
|
+
const marker: TransportMarker = {
|
|
576
|
+
agentId: args.agentId,
|
|
577
|
+
transport: "tmux-push",
|
|
578
|
+
pid,
|
|
579
|
+
tmuxTarget: target,
|
|
580
|
+
since: Date.now(),
|
|
581
|
+
};
|
|
582
|
+
// Use updateJson so it lockfile-protects and creates the file atomically.
|
|
583
|
+
await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
|
|
584
|
+
|
|
585
|
+
// Best-effort scan for a peek-coord.mjs hook wired to the same agentId —
|
|
586
|
+
// both consumers share the cursor file and would race / double-deliver.
|
|
587
|
+
const conflictingHook = await detectPeekCoordHook(args.agentId);
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
ok: true,
|
|
591
|
+
agentId: args.agentId,
|
|
592
|
+
transport: "tmux-push",
|
|
593
|
+
tmuxTarget: target,
|
|
594
|
+
pid,
|
|
595
|
+
log,
|
|
596
|
+
...(conflictingHook
|
|
597
|
+
? {
|
|
598
|
+
warnings: [
|
|
599
|
+
`peek-coord.mjs hook for agentId='${args.agentId}' detected in ${conflictingHook}. ` +
|
|
600
|
+
`Running both transports causes double-delivery — disable one. ` +
|
|
601
|
+
`Recommend removing the peek-coord hook entry since tmux-push supersedes it.`,
|
|
602
|
+
],
|
|
603
|
+
}
|
|
604
|
+
: {}),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function detectPeekCoordHook(agentId: string): Promise<string | undefined> {
|
|
609
|
+
const home = process.env.HOME ?? "";
|
|
610
|
+
const cwd = process.cwd();
|
|
611
|
+
const candidates = [
|
|
612
|
+
path.join(home, ".claude", "settings.json"),
|
|
613
|
+
path.join(home, ".claude", "settings.local.json"),
|
|
614
|
+
path.join(cwd, ".claude", "settings.json"),
|
|
615
|
+
path.join(cwd, ".claude", "settings.local.json"),
|
|
616
|
+
];
|
|
617
|
+
for (const file of candidates) {
|
|
618
|
+
if (!existsSync(file)) continue;
|
|
619
|
+
try {
|
|
620
|
+
const raw = await fsp.readFile(file, "utf8");
|
|
621
|
+
if (raw.includes("peek-coord.mjs") && raw.includes(`AGENT_COORD_ID=${agentId}`)) {
|
|
622
|
+
return file;
|
|
623
|
+
}
|
|
624
|
+
} catch {
|
|
625
|
+
// unreadable, skip
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export const detachAgentSchema = {
|
|
632
|
+
agentId: z.string().min(1),
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
export async function detachAgentTool(args: { agentId: string }) {
|
|
636
|
+
const marker = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
|
|
637
|
+
let killed = false;
|
|
638
|
+
if (marker && isPidAlive(marker.pid)) {
|
|
639
|
+
try {
|
|
640
|
+
process.kill(marker.pid, "SIGTERM");
|
|
641
|
+
killed = true;
|
|
642
|
+
} catch {
|
|
643
|
+
// already gone
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
await deleteFile(transportFile(args.agentId));
|
|
647
|
+
await deleteFile(pidFile(args.agentId, "pusher"));
|
|
648
|
+
return { ok: true, agentId: args.agentId, killed, hadMarker: marker !== null };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function resolvePusherPath(): string {
|
|
652
|
+
// tools.js (compiled) lives in dist/; pusher lives in hooks/ at repo root.
|
|
653
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
654
|
+
return path.resolve(here, "..", "hooks", "tmux-pusher.mjs");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------- status / whoami ----------
|
|
658
|
+
|
|
659
|
+
export const statusSchema = { agentId: z.string().min(1) };
|
|
660
|
+
|
|
661
|
+
export async function statusTool(args: { agentId: string }) {
|
|
662
|
+
const reg = await readJson<AgentRegistry>(AGENTS_FILE, {});
|
|
663
|
+
const entry = reg[args.agentId];
|
|
664
|
+
const transports = await loadLiveTransports();
|
|
665
|
+
const transport = transports.get(args.agentId);
|
|
666
|
+
const inbox = await readJsonl<Message>(inboxFile(args.agentId));
|
|
667
|
+
const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
|
|
668
|
+
const inboxOffset = cursor.inboxOffset ?? 0;
|
|
669
|
+
const unread = Math.max(0, inbox.length - inboxOffset);
|
|
670
|
+
return {
|
|
671
|
+
agentId: args.agentId,
|
|
672
|
+
registered: !!entry,
|
|
673
|
+
entry,
|
|
674
|
+
attached: !!transport,
|
|
675
|
+
transport,
|
|
676
|
+
inboxDepth: inbox.length,
|
|
677
|
+
inboxUnread: unread,
|
|
678
|
+
inTmux: !!process.env.TMUX_PANE,
|
|
679
|
+
tmuxPane: process.env.TMUX_PANE,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ---------- join (combo: register + auto-attach + read inbox) ----------
|
|
684
|
+
|
|
685
|
+
const joinAttachOptionsSchema = z.object({
|
|
686
|
+
tmuxTarget: z.string().optional(),
|
|
687
|
+
includeRoom: z.boolean().optional(),
|
|
688
|
+
allowlist: z.array(z.string()).optional(),
|
|
689
|
+
debounceMs: z.number().int().positive().max(60_000).optional(),
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
export const joinSchema = {
|
|
693
|
+
agentId: z.string().min(1),
|
|
694
|
+
project: z.string().optional(),
|
|
695
|
+
role: z.string().optional(),
|
|
696
|
+
// attach: undefined → auto-attach if $TMUX_PANE is set; true → always try;
|
|
697
|
+
// false → never; object → attach with overrides.
|
|
698
|
+
attach: z.union([z.boolean(), joinAttachOptionsSchema]).optional(),
|
|
699
|
+
readInbox: z.boolean().optional(),
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
export async function joinTool(args: {
|
|
703
|
+
agentId: string;
|
|
704
|
+
project?: string;
|
|
705
|
+
role?: string;
|
|
706
|
+
attach?: boolean | { tmuxTarget?: string; includeRoom?: boolean; allowlist?: string[]; debounceMs?: number };
|
|
707
|
+
readInbox?: boolean;
|
|
708
|
+
}) {
|
|
709
|
+
const reg = await registerTool({
|
|
710
|
+
agentId: args.agentId,
|
|
711
|
+
project: args.project,
|
|
712
|
+
role: args.role,
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Decide attach behavior.
|
|
716
|
+
const wantAttach = args.attach === false
|
|
717
|
+
? false
|
|
718
|
+
: args.attach === true || typeof args.attach === "object"
|
|
719
|
+
? true
|
|
720
|
+
: !!process.env.TMUX_PANE; // undefined → auto-detect
|
|
721
|
+
|
|
722
|
+
let attach: Awaited<ReturnType<typeof attachAgentTool>> | undefined;
|
|
723
|
+
if (wantAttach) {
|
|
724
|
+
const opts = typeof args.attach === "object" ? args.attach : {};
|
|
725
|
+
attach = await attachAgentTool({ agentId: args.agentId, ...opts });
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const readInbox = args.readInbox ?? true;
|
|
729
|
+
let inbox: Awaited<ReturnType<typeof readMessagesTool>> | undefined;
|
|
730
|
+
if (readInbox) {
|
|
731
|
+
inbox = await readMessagesTool({ agentId: args.agentId, source: "inbox" });
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return {
|
|
735
|
+
ok: true,
|
|
736
|
+
registered: reg.agent,
|
|
737
|
+
attached: !!attach && attach.ok !== false,
|
|
738
|
+
attach,
|
|
739
|
+
inbox,
|
|
740
|
+
inTmux: !!process.env.TMUX_PANE,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
387
744
|
// ---------- helpers ----------
|
|
388
745
|
|
|
389
746
|
function sourceFile(source: "inbox" | "room" | "status", agentId: string): string {
|