@jameslovespancakes/pi-plus 1.0.0 → 1.0.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/LICENSE +21 -21
- package/README.md +190 -190
- package/config/pi-plus.example.json +60 -60
- package/config/skills/model-routing/SKILL.md +86 -86
- package/images/pi-plus.svg +10 -10
- package/package.json +67 -67
- package/server/board-server.mjs +641 -641
- package/server/package.json +17 -17
- package/src/core/accounts/registry.ts +93 -93
- package/src/core/anthropic/client-identity.ts +241 -241
- package/src/core/catalog/quality.ts +314 -314
- package/src/core/config.ts +169 -169
- package/src/core/env.ts +58 -58
- package/src/core/exec/process.ts +146 -146
- package/src/core/exec/ssh-config.ts +157 -157
- package/src/core/policy/policy.ts +183 -183
- package/src/core/quota/pool.ts +64 -64
- package/src/core/quota/usage-source.ts +289 -289
- package/src/core/store.ts +43 -43
- package/src/domains/agents/board-setup.ts +409 -409
- package/src/domains/agents/index.ts +462 -462
- package/src/domains/models/catalog-tool.ts +361 -361
- package/src/domains/models/index.ts +14 -14
- package/src/domains/models/policy-gate.ts +169 -169
- package/src/domains/models/provider-picker.ts +207 -207
- package/src/domains/remote/config-path.ts +41 -41
- package/src/domains/remote/index.ts +866 -866
- package/src/domains/remote/setup.ts +425 -425
- package/src/domains/setup/index.ts +220 -220
- package/src/domains/subscriptions/accounts.ts +242 -242
- package/src/domains/subscriptions/footer.ts +182 -182
- package/src/domains/subscriptions/index.ts +42 -42
- package/src/domains/subscriptions/provider.ts +219 -219
- package/src/domains/subscriptions/providers/anthropic.ts +149 -149
- package/src/domains/subscriptions/providers/codex.ts +148 -148
- package/src/domains/subscriptions/routing.ts +72 -72
- package/src/services/usage-service.ts +186 -186
- package/src/ui/format.ts +73 -73
- package/src/ui/usage-bars.ts +154 -154
- package/src/vendor/anthropic.ts +109 -109
|
@@ -1,462 +1,462 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
6
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
9
|
-
import { Input, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
10
|
-
import { Type } from "typebox";
|
|
11
|
-
import WebSocket from "ws";
|
|
12
|
-
import { env } from "../../core/env.ts";
|
|
13
|
-
import { handleBoardAdmin, registerBoardLifecycle } from "./board-setup.ts";
|
|
14
|
-
|
|
15
|
-
const HEARTBEAT_MS = 8_000;
|
|
16
|
-
const REQUEST_TIMEOUT_MS = 2_000;
|
|
17
|
-
const SNAPSHOT_CACHE_MS = 1_500;
|
|
18
|
-
const MESSAGE_MAX = 8_000;
|
|
19
|
-
|
|
20
|
-
type State = "idle" | "thinking" | "tool";
|
|
21
|
-
type Priority = "normal" | "urgent";
|
|
22
|
-
|
|
23
|
-
interface Config { url: string; token: string; adminName?: string; }
|
|
24
|
-
interface AgentInfo {
|
|
25
|
-
sessionId: string; alias?: string; host: string; cwd: string; branch?: string;
|
|
26
|
-
repo?: string; commit?: string; commitTime?: number; commitSubject?: string;
|
|
27
|
-
model?: string; state: State; lastTool?: string; lastPrompt?: string; lastSeenAt?: number;
|
|
28
|
-
key?: string; coordinator?: string | null; reports?: string[];
|
|
29
|
-
}
|
|
30
|
-
interface ThreadInfo { id: string; kind: "direct" | "group"; title?: string; auto?: boolean; repoKey?: string; participantIds: string[]; participants: AgentInfo[]; lastMessage?: BoardMessage | null; }
|
|
31
|
-
interface Coordination { key?: string; coordinator?: string | null; reports?: string[]; repoThread?: string; }
|
|
32
|
-
interface BoardMessage { id: string; threadId: string; senderType: "user" | "agent"; senderId: string; senderAlias?: string; text: string; priority: Priority; createdAt: number; }
|
|
33
|
-
interface GitInfo { branch?: string; repo?: string; commit?: string; commitTime?: number; commitSubject?: string; dirty?: boolean; }
|
|
34
|
-
|
|
35
|
-
type Listener = (event: any) => void;
|
|
36
|
-
|
|
37
|
-
function configDir(): string { return process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent"); }
|
|
38
|
-
function loadConfig(): Config | undefined {
|
|
39
|
-
// Unified store first; the standalone agent-board.json is still honoured.
|
|
40
|
-
const url = env("AGENT_BOARD_URL");
|
|
41
|
-
const token = env("AGENT_BOARD_TOKEN");
|
|
42
|
-
if (url && token) return { url, token, adminName: env("AGENT_BOARD_NAME") };
|
|
43
|
-
try {
|
|
44
|
-
const value = JSON.parse(fs.readFileSync(path.join(configDir(), "agent-board.json"), "utf8"));
|
|
45
|
-
if (typeof value.url === "string" && typeof value.token === "string") return value;
|
|
46
|
-
} catch {}
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
function cleanLine(value: unknown, max = 140): string {
|
|
50
|
-
const text = String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
51
|
-
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
52
|
-
}
|
|
53
|
-
function git(cwd: string, args: string[]): string | undefined {
|
|
54
|
-
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", timeout: 1_000, windowsHide: true });
|
|
55
|
-
return result.status === 0 ? result.stdout.trim() || undefined : undefined;
|
|
56
|
-
}
|
|
57
|
-
function normalizeRemote(remote?: string): string | undefined {
|
|
58
|
-
if (!remote) return undefined;
|
|
59
|
-
return remote.replace(/^git@([^:]+):/, "https://$1/").replace(/\.git$/, "").toLowerCase();
|
|
60
|
-
}
|
|
61
|
-
function readGit(cwd: string): GitInfo {
|
|
62
|
-
const meta = git(cwd, ["show", "-s", "--format=%H%n%ct%n%s", "HEAD"])?.split("\n") ?? [];
|
|
63
|
-
return {
|
|
64
|
-
branch: git(cwd, ["branch", "--show-current"]),
|
|
65
|
-
repo: normalizeRemote(git(cwd, ["remote", "get-url", "origin"])) ?? git(cwd, ["rev-parse", "--show-toplevel"]),
|
|
66
|
-
commit: meta[0],
|
|
67
|
-
commitTime: meta[1] ? Number(meta[1]) * 1_000 : undefined,
|
|
68
|
-
commitSubject: meta[2],
|
|
69
|
-
dirty: Boolean(git(cwd, ["status", "--porcelain"])),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
class BoardClient {
|
|
74
|
-
private ws?: WebSocket;
|
|
75
|
-
private stopped = false;
|
|
76
|
-
private reconnect?: ReturnType<typeof setTimeout>;
|
|
77
|
-
private heartbeat?: ReturnType<typeof setInterval>;
|
|
78
|
-
private attempts = 0;
|
|
79
|
-
private pending = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
80
|
-
private listeners = new Set<Listener>();
|
|
81
|
-
private agent?: AgentInfo;
|
|
82
|
-
connected = false;
|
|
83
|
-
coordination: Coordination = {};
|
|
84
|
-
|
|
85
|
-
constructor(private readonly config: Config) {}
|
|
86
|
-
start(agent: AgentInfo): void { this.agent = agent; this.stopped = false; this.connect(); }
|
|
87
|
-
stop(): void {
|
|
88
|
-
this.stopped = true; this.connected = false;
|
|
89
|
-
if (this.reconnect) clearTimeout(this.reconnect);
|
|
90
|
-
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
91
|
-
this.ws?.close(1000, "pi session shutdown"); this.ws = undefined;
|
|
92
|
-
for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(new Error("Agent board disconnected")); }
|
|
93
|
-
this.pending.clear();
|
|
94
|
-
}
|
|
95
|
-
on(listener: Listener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
96
|
-
private emit(event: any): void { for (const listener of this.listeners) { try { listener(event); } catch {} } }
|
|
97
|
-
private connect(): void {
|
|
98
|
-
if (this.stopped || !this.agent) return;
|
|
99
|
-
const ws = new WebSocket(this.config.url);
|
|
100
|
-
this.ws = ws;
|
|
101
|
-
ws.on("open", () => ws.send(JSON.stringify({ t: "register", token: this.config.token, agent: this.agent })));
|
|
102
|
-
ws.on("message", (raw) => {
|
|
103
|
-
let value: any; try { value = JSON.parse(raw.toString()); } catch { return; }
|
|
104
|
-
if (value.t === "registered") {
|
|
105
|
-
this.connected = true; this.attempts = 0;
|
|
106
|
-
this.coordination = { key: value.key, coordinator: value.coordinator, reports: value.reports, repoThread: value.repoThread };
|
|
107
|
-
this.emit({ t: "connection", connected: true });
|
|
108
|
-
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
109
|
-
this.heartbeat = setInterval(() => this.presence({}), HEARTBEAT_MS); this.heartbeat.unref?.();
|
|
110
|
-
} else if (value.t === "coordination") {
|
|
111
|
-
this.coordination = { ...this.coordination, key: value.key, coordinator: value.coordinator, reports: value.reports };
|
|
112
|
-
this.emit(value);
|
|
113
|
-
} else if (value.t === "thread") {
|
|
114
|
-
this.emit(value);
|
|
115
|
-
} else if (value.t === "res") {
|
|
116
|
-
const item = this.pending.get(value.id); if (!item) return;
|
|
117
|
-
this.pending.delete(value.id); clearTimeout(item.timer);
|
|
118
|
-
if (value.ok) item.resolve(value.data);
|
|
119
|
-
else item.reject(new Error(value.error || "Board request failed"));
|
|
120
|
-
} else if (value.t === "message") this.emit(value);
|
|
121
|
-
});
|
|
122
|
-
const disconnected = () => {
|
|
123
|
-
if (this.ws !== ws) return;
|
|
124
|
-
this.connected = false; this.emit({ t: "connection", connected: false });
|
|
125
|
-
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
126
|
-
if (!this.stopped) {
|
|
127
|
-
const delay = Math.min(15_000, 500 * 2 ** Math.min(this.attempts++, 5));
|
|
128
|
-
this.reconnect = setTimeout(() => this.connect(), delay); this.reconnect.unref?.();
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
ws.once("close", disconnected); ws.once("error", disconnected);
|
|
132
|
-
}
|
|
133
|
-
presence(patch: Partial<AgentInfo>): void {
|
|
134
|
-
if (!this.agent) return;
|
|
135
|
-
this.agent = { ...this.agent, ...patch };
|
|
136
|
-
if (this.ws?.readyState === WebSocket.OPEN && this.connected) this.ws.send(JSON.stringify({ t: "presence", agent: patch }));
|
|
137
|
-
}
|
|
138
|
-
activity(type: string, summary: string): void {
|
|
139
|
-
if (this.ws?.readyState === WebSocket.OPEN && this.connected) this.ws.send(JSON.stringify({ t: "activity", event: { type, summary: cleanLine(summary), at: Date.now() } }));
|
|
140
|
-
}
|
|
141
|
-
request(action: string, input: Record<string, unknown> = {}, timeout = REQUEST_TIMEOUT_MS): Promise<any> {
|
|
142
|
-
if (!this.connected || this.ws?.readyState !== WebSocket.OPEN) return Promise.reject(new Error("Agent board is not connected"));
|
|
143
|
-
const id = randomUUID();
|
|
144
|
-
return new Promise((resolve, reject) => {
|
|
145
|
-
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error("Agent board request timed out")); }, timeout);
|
|
146
|
-
timer.unref?.(); this.pending.set(id, { resolve, reject, timer });
|
|
147
|
-
this.ws!.send(JSON.stringify({ t: "req", id, action, ...input }));
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
class BoardView implements Component, Focusable {
|
|
153
|
-
private input = new Input();
|
|
154
|
-
private threads: ThreadInfo[] = [];
|
|
155
|
-
private messages: BoardMessage[] = [];
|
|
156
|
-
private selected = 0;
|
|
157
|
-
private sending = false;
|
|
158
|
-
private error?: string;
|
|
159
|
-
private initialRecipients?: string[];
|
|
160
|
-
private subscribedThread?: string;
|
|
161
|
-
private unsubscribe: () => void;
|
|
162
|
-
private _focused = false;
|
|
163
|
-
get focused(): boolean { return this._focused; }
|
|
164
|
-
set focused(value: boolean) { this._focused = value; this.input.focused = value; }
|
|
165
|
-
|
|
166
|
-
constructor(
|
|
167
|
-
private readonly client: BoardClient,
|
|
168
|
-
private readonly tui: any,
|
|
169
|
-
private readonly theme: any,
|
|
170
|
-
private readonly done: () => void,
|
|
171
|
-
recipients: string[],
|
|
172
|
-
) {
|
|
173
|
-
this.initialRecipients = recipients.length ? recipients : undefined;
|
|
174
|
-
this.input.onSubmit = (value) => void this.send(value, "normal");
|
|
175
|
-
this.input.onEscape = () => this.close();
|
|
176
|
-
this.unsubscribe = client.on((event) => {
|
|
177
|
-
if (event.t === "connection") {
|
|
178
|
-
this.error = event.connected ? undefined : "Messaging Board is offline, reconnecting…";
|
|
179
|
-
if (event.connected) void this.load();
|
|
180
|
-
this.tui.requestRender();
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
if (event.t !== "message") return;
|
|
184
|
-
const index = this.threads.findIndex((thread) => thread.id === event.thread.id);
|
|
185
|
-
if (index < 0) this.threads.unshift(event.thread); else this.threads[index] = event.thread;
|
|
186
|
-
if (this.current()?.id === event.message.threadId) this.messages.push(event.message);
|
|
187
|
-
this.tui.requestRender();
|
|
188
|
-
});
|
|
189
|
-
void this.load();
|
|
190
|
-
}
|
|
191
|
-
private current(): ThreadInfo | undefined { return this.threads[this.selected]; }
|
|
192
|
-
private async load(): Promise<void> {
|
|
193
|
-
try {
|
|
194
|
-
this.threads = await this.client.request("threads", { actor: "user" });
|
|
195
|
-
if (this.threads.length) await this.loadMessages();
|
|
196
|
-
} catch (error) { this.error = (error as Error).message; }
|
|
197
|
-
this.tui.requestRender();
|
|
198
|
-
}
|
|
199
|
-
private async loadMessages(): Promise<void> {
|
|
200
|
-
const thread = this.current();
|
|
201
|
-
if (!thread) { this.messages = []; return; }
|
|
202
|
-
try {
|
|
203
|
-
const previous = this.subscribedThread;
|
|
204
|
-
this.subscribedThread = thread.id;
|
|
205
|
-
await this.client.request("subscribe", { actor: "user", thread: thread.id, previous });
|
|
206
|
-
this.messages = (await this.client.request("messages", { actor: "user", thread: thread.id, limit: 100 })).messages;
|
|
207
|
-
} catch (error) { this.error = (error as Error).message; }
|
|
208
|
-
}
|
|
209
|
-
private async send(value: string, priority: Priority): Promise<void> {
|
|
210
|
-
const text = value.trim(); if (!text || this.sending) return;
|
|
211
|
-
this.sending = true; this.error = undefined;
|
|
212
|
-
try {
|
|
213
|
-
const result = await this.client.request("send", {
|
|
214
|
-
actor: "user", thread: this.current()?.id, recipients: this.current() ? undefined : this.initialRecipients,
|
|
215
|
-
message: text.slice(0, MESSAGE_MAX), priority,
|
|
216
|
-
});
|
|
217
|
-
if (!this.current()) {
|
|
218
|
-
this.threads.unshift(result.thread); this.selected = 0; this.initialRecipients = undefined;
|
|
219
|
-
await this.client.request("subscribe", { actor: "user", thread: result.thread.id, previous: this.subscribedThread });
|
|
220
|
-
this.subscribedThread = result.thread.id;
|
|
221
|
-
}
|
|
222
|
-
this.messages.push(result.message); this.input.setValue("");
|
|
223
|
-
} catch (error) { this.error = (error as Error).message; }
|
|
224
|
-
finally { this.sending = false; this.tui.requestRender(); }
|
|
225
|
-
}
|
|
226
|
-
private close(): void {
|
|
227
|
-
this.unsubscribe();
|
|
228
|
-
if (this.subscribedThread) void this.client.request("subscribe", { actor: "user", previous: this.subscribedThread }).catch(() => {});
|
|
229
|
-
this.done();
|
|
230
|
-
}
|
|
231
|
-
handleInput(data: string): void {
|
|
232
|
-
if (matchesKey(data, Key.escape)) return this.close();
|
|
233
|
-
if (matchesKey(data, Key.tab) && this.threads.length > 1) {
|
|
234
|
-
this.selected = (this.selected + 1) % this.threads.length; void this.loadMessages(); this.tui.requestRender(); return;
|
|
235
|
-
}
|
|
236
|
-
if (matchesKey(data, Key.ctrl("enter"))) {
|
|
237
|
-
const value = this.input.getValue();
|
|
238
|
-
if (value) void this.send(value, "urgent");
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
this.input.handleInput(data); this.tui.requestRender();
|
|
242
|
-
}
|
|
243
|
-
invalidate(): void { this.input.invalidate(); }
|
|
244
|
-
render(width: number): string[] {
|
|
245
|
-
const w = Math.max(32, width);
|
|
246
|
-
const inner = Math.max(1, w - 4);
|
|
247
|
-
const th = this.theme;
|
|
248
|
-
const status = this.client.connected
|
|
249
|
-
? `${th.fg("success", "●")} ${th.fg("success", th.bold("Active"))}`
|
|
250
|
-
: `${th.fg("error", "●")} ${th.fg("error", th.bold("Offline"))}`;
|
|
251
|
-
const tabs = this.threads.length
|
|
252
|
-
? this.threads.slice(0, 6).map((item, index) => {
|
|
253
|
-
const label = ` ${index + 1} ${item.title || item.id.slice(0, 8)} `;
|
|
254
|
-
return index === this.selected
|
|
255
|
-
? th.bg("selectedBg", th.fg("accent", th.bold(label)))
|
|
256
|
-
: th.fg("muted", label);
|
|
257
|
-
}).join(th.fg("dim", "│"))
|
|
258
|
-
: th.fg("dim", this.initialRecipients ? ` New chat: ${this.initialRecipients.join(", ")} ` : " No chats. Use /board <agent> to start one ");
|
|
259
|
-
const messageLines: string[] = [];
|
|
260
|
-
for (const message of this.messages) {
|
|
261
|
-
const time = new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
262
|
-
const sender = message.senderType === "user" ? "you" : (message.senderAlias || message.senderId.slice(0, 8));
|
|
263
|
-
const marker = message.priority === "urgent" ? th.fg("warning", "!") : th.fg("dim", "›");
|
|
264
|
-
const label = `${marker} ${message.senderType === "user" ? th.fg("success", th.bold(sender)) : th.fg("accent", th.bold(sender))} ${th.fg("dim", time)}`;
|
|
265
|
-
messageLines.push(label);
|
|
266
|
-
for (const line of wrapTextWithAnsi(message.text, Math.max(10, inner - 4))) messageLines.push(` ${line}`);
|
|
267
|
-
messageLines.push("");
|
|
268
|
-
}
|
|
269
|
-
if (!messageLines.length) messageLines.push(th.fg("dim", "No messages yet."));
|
|
270
|
-
const popupHeight = Math.max(12, Math.min((this.tui.terminal?.rows ?? 30) - 6, 30));
|
|
271
|
-
const bodyHeight = Math.max(3, popupHeight - 8 - (this.error ? 1 : 0));
|
|
272
|
-
const visibleMessages = messageLines.slice(-bodyHeight);
|
|
273
|
-
while (visibleMessages.length < bodyHeight) visibleMessages.unshift("");
|
|
274
|
-
const inputLine = this.input.render(Math.max(1, inner - 11))[0] ?? "";
|
|
275
|
-
const content = [
|
|
276
|
-
`${th.fg("accent", th.bold("Messaging Board"))} ${status} ${th.fg("dim", `· ${this.threads.length} chat${this.threads.length === 1 ? "" : "s"}`)}`,
|
|
277
|
-
tabs,
|
|
278
|
-
th.fg("dim", "─".repeat(inner)),
|
|
279
|
-
...visibleMessages,
|
|
280
|
-
th.fg("dim", "─".repeat(inner)),
|
|
281
|
-
...(this.error ? [th.fg("error", this.error)] : []),
|
|
282
|
-
`${th.fg("accent", th.bold("Message"))} ${inputLine}`,
|
|
283
|
-
th.fg("dim", "enter send · tab next chat · esc close"),
|
|
284
|
-
];
|
|
285
|
-
const row = (value: string): string => {
|
|
286
|
-
const fitted = truncateToWidth(value, inner, "");
|
|
287
|
-
return `${th.fg("border", "│")} ${fitted}${" ".repeat(Math.max(0, inner - visibleWidth(fitted)))} ${th.fg("border", "│")}`;
|
|
288
|
-
};
|
|
289
|
-
return [
|
|
290
|
-
th.fg("border", `╭${"─".repeat(Math.max(0, w - 2))}╮`),
|
|
291
|
-
...content.map(row),
|
|
292
|
-
th.fg("border", `╰${"─".repeat(Math.max(0, w - 2))}╯`),
|
|
293
|
-
];
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export default function (pi: ExtensionAPI) {
|
|
298
|
-
const config = loadConfig();
|
|
299
|
-
if (!config) return;
|
|
300
|
-
const client = new BoardClient(config);
|
|
301
|
-
let ctx: ExtensionContext | undefined;
|
|
302
|
-
let self: AgentInfo | undefined;
|
|
303
|
-
let gitInfo: GitInfo = {};
|
|
304
|
-
let snapshot: { at: number; agents: AgentInfo[] } = { at: 0, agents: [] };
|
|
305
|
-
|
|
306
|
-
const updateStatus = () => {
|
|
307
|
-
if (!ctx?.hasUI) return;
|
|
308
|
-
const color = client.connected ? "success" : "error";
|
|
309
|
-
const label = client.connected ? "Active" : "Offline";
|
|
310
|
-
ctx.ui.setStatus("agent-board", ctx.ui.theme.fg(color, `● Messaging Board ${label}`));
|
|
311
|
-
};
|
|
312
|
-
const refreshGit = () => {
|
|
313
|
-
if (!ctx || !self) return;
|
|
314
|
-
gitInfo = readGit(ctx.cwd);
|
|
315
|
-
self = { ...self, ...gitInfo };
|
|
316
|
-
client.presence(gitInfo);
|
|
317
|
-
};
|
|
318
|
-
const formatCoordination = (): string[] => {
|
|
319
|
-
const { coordinator, reports, repoThread } = client.coordination;
|
|
320
|
-
const lines: string[] = [];
|
|
321
|
-
if (coordinator) lines.push(`You report to coordinator '${coordinator}'. Use agent_board action 'report' to send them progress, blockers, and completed work.`);
|
|
322
|
-
if (reports?.length) lines.push(`You are the coordinator for: ${reports.join(", ")}. Their reports arrive as agent-board messages; keep their work aligned.`);
|
|
323
|
-
if (repoThread) lines.push(`Shared repo room: thread '${repoThread}' automatically contains every agent working in this repository. Post repo-wide notices there with agent_board send.`);
|
|
324
|
-
return lines;
|
|
325
|
-
};
|
|
326
|
-
const formatSnapshot = (agents: AgentInfo[]): string => {
|
|
327
|
-
const coordinationLines = formatCoordination();
|
|
328
|
-
if (!agents.length) return ["[Live agent board]\nNo other Pi agents are currently running.", ...coordinationLines].join("\n");
|
|
329
|
-
const lines = agents.slice(0, 20).map((agent) => {
|
|
330
|
-
const commit = agent.commit ? agent.commit.slice(0, 12) : "no-commit";
|
|
331
|
-
const sameLine = agent.repo && gitInfo.repo === agent.repo && agent.branch === gitInfo.branch;
|
|
332
|
-
const divergent = sameLine && agent.commit && gitInfo.commit && agent.commit !== gitInfo.commit ? " ⚠ different commit" : "";
|
|
333
|
-
const activity = agent.state === "tool" && agent.lastTool ? agent.lastTool : agent.state;
|
|
334
|
-
const role = agent.reports?.length ? " [coordinator]" : agent.coordinator ? ` [reports to ${agent.coordinator}]` : "";
|
|
335
|
-
return `- ${agent.alias || agent.sessionId.slice(0, 8)}@${agent.host}${role}: ${activity}; ${agent.branch || "-"}@${commit}${divergent}${agent.lastPrompt ? `; ${agent.lastPrompt}` : ""}`;
|
|
336
|
-
});
|
|
337
|
-
return [
|
|
338
|
-
`[Live agent board, consult before working]`,
|
|
339
|
-
`Your commit: ${gitInfo.branch || "-"}@${gitInfo.commit?.slice(0, 12) || "no-commit"}`,
|
|
340
|
-
`Running collaborators:`,
|
|
341
|
-
lines.join("\n"),
|
|
342
|
-
...coordinationLines,
|
|
343
|
-
`Avoid duplicate or outdated work. Use agent_board to coordinate when useful.`,
|
|
344
|
-
].join("\n");
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
client.on((event) => {
|
|
348
|
-
if (event.t === "connection" || event.t === "coordination") { updateStatus(); return; }
|
|
349
|
-
if (event.t !== "message" || event.adminView || !ctx) return;
|
|
350
|
-
const message = event.message as BoardMessage;
|
|
351
|
-
const thread = event.thread as ThreadInfo;
|
|
352
|
-
const content = `[Live agent-board message${message.priority === "urgent" ? " URGENT" : ""}]\nFrom: ${message.senderAlias || message.senderId}\nThread: ${thread.title || thread.id}\nCommit context: you are on ${gitInfo.branch || "-"}@${gitInfo.commit?.slice(0, 12) || "no-commit"}\n\n${message.text}\n\nEvaluate this at the next decision point. If relevant, adjust your work and reply through agent_board with thread '${thread.id}'. If irrelevant, continue the current task.`;
|
|
353
|
-
if (message.priority === "urgent" && !ctx.isIdle()) ctx.abort();
|
|
354
|
-
pi.sendMessage({ customType: "agent-board", content, display: true, details: { message, thread } }, { deliverAs: "steer", triggerTurn: true });
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
pi.on("session_start", async (_event, eventCtx) => {
|
|
358
|
-
if (!eventCtx.hasUI) return;
|
|
359
|
-
ctx = eventCtx; gitInfo = readGit(eventCtx.cwd);
|
|
360
|
-
self = {
|
|
361
|
-
sessionId: eventCtx.sessionManager.getSessionId(), alias: pi.getSessionName(), host: os.hostname(), cwd: eventCtx.cwd,
|
|
362
|
-
model: eventCtx.model ? `${eventCtx.model.provider}/${eventCtx.model.id}` : undefined, state: "idle", ...gitInfo,
|
|
363
|
-
};
|
|
364
|
-
client.start(self); updateStatus();
|
|
365
|
-
});
|
|
366
|
-
pi.on("session_info_changed", async (event) => { if (self) { self.alias = event.name; client.presence({ alias: event.name }); } });
|
|
367
|
-
pi.on("model_select", async (event) => client.presence({ model: `${event.model.provider}/${event.model.id}` }));
|
|
368
|
-
pi.on("before_agent_start", async (event) => {
|
|
369
|
-
client.presence({ state: "thinking", lastPrompt: cleanLine(event.prompt) });
|
|
370
|
-
client.activity("prompt", event.prompt);
|
|
371
|
-
});
|
|
372
|
-
pi.on("tool_execution_start", async (event) => {
|
|
373
|
-
const lastTool = cleanLine(`${event.toolName}: ${JSON.stringify(event.args)}`, 100);
|
|
374
|
-
client.presence({ state: "tool", lastTool }); client.activity("tool", lastTool);
|
|
375
|
-
});
|
|
376
|
-
pi.on("tool_execution_end", async () => client.presence({ state: "thinking" }));
|
|
377
|
-
pi.on("agent_settled", async () => { client.presence({ state: "idle", lastTool: undefined }); refreshGit(); });
|
|
378
|
-
pi.on("session_shutdown", async (_event, eventCtx) => { client.stop(); eventCtx.ui.setStatus("agent-board", undefined); ctx = undefined; self = undefined; });
|
|
379
|
-
|
|
380
|
-
// A transient, late context message avoids growing the session and keeps the static prompt cache stable.
|
|
381
|
-
pi.on("context", async (event) => {
|
|
382
|
-
if (!client.connected) return;
|
|
383
|
-
try {
|
|
384
|
-
if (Date.now() - snapshot.at > SNAPSHOT_CACHE_MS) snapshot = { at: Date.now(), agents: await client.request("agents", {}, 500) };
|
|
385
|
-
return {
|
|
386
|
-
messages: [...event.messages, {
|
|
387
|
-
role: "custom" as const, customType: "agent-board-snapshot", content: formatSnapshot(snapshot.agents),
|
|
388
|
-
display: false, details: {}, timestamp: Date.now(),
|
|
389
|
-
}],
|
|
390
|
-
};
|
|
391
|
-
} catch { return; }
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
pi.registerMessageRenderer("agent-board", (message, _options, theme) => new Text(theme.fg("accent", message.content as string), 1, 0));
|
|
395
|
-
|
|
396
|
-
registerBoardLifecycle(pi);
|
|
397
|
-
|
|
398
|
-
pi.registerCommand("board", {
|
|
399
|
-
description: "Live agent board, or setup | restart | clear | status",
|
|
400
|
-
getArgumentCompletions: (prefix) =>
|
|
401
|
-
["setup", "restart", "clear", "status"]
|
|
402
|
-
.filter((option) => option.startsWith(prefix))
|
|
403
|
-
.map((option) => ({ value: option, label: option })),
|
|
404
|
-
handler: async (args, commandCtx) => {
|
|
405
|
-
// Admin verbs run without the TUI; anything else opens the board view.
|
|
406
|
-
if (await handleBoardAdmin(pi, commandCtx, args)) return;
|
|
407
|
-
if (commandCtx.mode !== "tui") { commandCtx.ui.notify("The Messaging Board UI requires TUI mode.", "warning"); return; }
|
|
408
|
-
const recipients = args.split(/[ ,]+/).map((value) => value.trim()).filter(Boolean);
|
|
409
|
-
await commandCtx.ui.custom<void>(
|
|
410
|
-
(tui, theme, _keys, done) => new BoardView(client, tui, theme, done, recipients),
|
|
411
|
-
{ overlay: true, overlayOptions: { anchor: "center", width: "86%", minWidth: 56, maxHeight: "85%", margin: 1 } },
|
|
412
|
-
);
|
|
413
|
-
},
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
pi.registerTool({
|
|
417
|
-
name: "agent_board",
|
|
418
|
-
label: "Agent Board",
|
|
419
|
-
description: "Consult and message all currently running local and remote Pi agents. Every agent record includes host, state, Git branch, and commit hash. Messages are live: idle agents wake immediately and busy agents see them at the next safe turn boundary. Stopped agents are never listed and messages are not queued for them. Agents working in the same repository share an auto-created repo room, and coordinators can be assigned so subordinate agents report their progress upward.",
|
|
420
|
-
promptSnippet: "Consult live collaborators, report to your coordinator, and exchange direct, repo-room, or group messages",
|
|
421
|
-
promptGuidelines: [
|
|
422
|
-
"Consult the transient Live agent board context before starting work; avoid duplicating work and compare Git commit hashes before relying on another agent's changes.",
|
|
423
|
-
"Use agent_board to inform collaborators when their commit or assumptions appear outdated, and reply only when coordination is useful.",
|
|
424
|
-
"If the board snapshot names a coordinator for you, use agent_board action 'report' to send them meaningful progress, blockers, and completed work instead of staying silent.",
|
|
425
|
-
"Use agent_board action 'set_coordinator' when the user puts one agent in charge of others, and 'coordinators' to inspect the current reporting structure.",
|
|
426
|
-
"Post repo-wide notices in the shared repo room thread so every agent in the same repository sees them.",
|
|
427
|
-
],
|
|
428
|
-
parameters: Type.Object({
|
|
429
|
-
action: StringEnum(["agents", "inspect", "threads", "read", "send", "report", "coordinators", "set_coordinator"] as const),
|
|
430
|
-
agent: Type.Optional(Type.String({ description: "For inspect: session id prefix, alias, or cwd substring. For set_coordinator: the coordinator's alias, or 'none' to clear" })),
|
|
431
|
-
thread: Type.Optional(Type.String({ description: "Thread id for read/send/reply" })),
|
|
432
|
-
recipients: Type.Optional(Type.Array(Type.String(), { description: "Active agent aliases or ids for a new direct/group chat; for set_coordinator, the agents that should report to the coordinator (defaults to yourself)" })),
|
|
433
|
-
message: Type.Optional(Type.String({ description: "Message text for send or report" })),
|
|
434
|
-
priority: Type.Optional(StringEnum(["normal", "urgent"] as const)),
|
|
435
|
-
limit: Type.Optional(Type.Number({ description: "Maximum messages/activity events to return" })),
|
|
436
|
-
}),
|
|
437
|
-
async execute(_id, params) {
|
|
438
|
-
const action = params.action === "read" ? "messages" : params.action;
|
|
439
|
-
const { action: _requestedAction, ...input } = params;
|
|
440
|
-
const data = await client.request(action, { ...input, actor: "agent" });
|
|
441
|
-
let text: string;
|
|
442
|
-
if (params.action === "agents") {
|
|
443
|
-
const agents = data as AgentInfo[];
|
|
444
|
-
text = agents.length ? agents.map((agent) => `${agent.sessionId.slice(0, 8)} ${agent.alias || "(unnamed)"}\n ${agent.host} · ${agent.cwd}\n ${agent.state}${agent.lastTool ? ` · ${agent.lastTool}` : ""}\n git ${agent.branch || "-"}@${agent.commit?.slice(0, 12) || "no-commit"}${agent.commitSubject ? ` · ${agent.commitSubject}` : ""}`).join("\n") : "No other Pi agents are currently running.";
|
|
445
|
-
} else if (params.action === "send" || params.action === "report") {
|
|
446
|
-
text = `Delivered to: ${data.delivered.join(", ") || "none"}.${data.notRunning.length ? ` Not running: ${data.notRunning.join(", ")}.` : ""} Thread: ${data.thread.id}`;
|
|
447
|
-
} else if (params.action === "set_coordinator") {
|
|
448
|
-
text = data.applied.length
|
|
449
|
-
? data.applied.map((item: any) => item.coordinator ? `${item.agent} now reports to ${item.coordinator}` : `${item.agent} no longer has a coordinator`).join("\n")
|
|
450
|
-
: "No agents matched; nothing changed.";
|
|
451
|
-
} else if (params.action === "coordinators") {
|
|
452
|
-
const links = (data.links as any[]).map((link) => `${link.agent}${link.agentOnline ? "" : " (offline)"} → ${link.coordinator}${link.coordinatorOnline ? "" : " (offline)"}`);
|
|
453
|
-
text = [
|
|
454
|
-
data.self.coordinator ? `You report to: ${data.self.coordinator}` : "You have no coordinator.",
|
|
455
|
-
data.self.reports.length ? `Reporting to you: ${data.self.reports.join(", ")}` : "No agents report to you.",
|
|
456
|
-
links.length ? `\nAll links:\n${links.join("\n")}` : "\nNo coordinator links are configured.",
|
|
457
|
-
].join("\n");
|
|
458
|
-
} else text = JSON.stringify(data, null, 2);
|
|
459
|
-
return { content: [{ type: "text", text }], details: data };
|
|
460
|
-
},
|
|
461
|
-
});
|
|
462
|
-
}
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
9
|
+
import { Input, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import WebSocket from "ws";
|
|
12
|
+
import { env } from "../../core/env.ts";
|
|
13
|
+
import { handleBoardAdmin, registerBoardLifecycle } from "./board-setup.ts";
|
|
14
|
+
|
|
15
|
+
const HEARTBEAT_MS = 8_000;
|
|
16
|
+
const REQUEST_TIMEOUT_MS = 2_000;
|
|
17
|
+
const SNAPSHOT_CACHE_MS = 1_500;
|
|
18
|
+
const MESSAGE_MAX = 8_000;
|
|
19
|
+
|
|
20
|
+
type State = "idle" | "thinking" | "tool";
|
|
21
|
+
type Priority = "normal" | "urgent";
|
|
22
|
+
|
|
23
|
+
interface Config { url: string; token: string; adminName?: string; }
|
|
24
|
+
interface AgentInfo {
|
|
25
|
+
sessionId: string; alias?: string; host: string; cwd: string; branch?: string;
|
|
26
|
+
repo?: string; commit?: string; commitTime?: number; commitSubject?: string;
|
|
27
|
+
model?: string; state: State; lastTool?: string; lastPrompt?: string; lastSeenAt?: number;
|
|
28
|
+
key?: string; coordinator?: string | null; reports?: string[];
|
|
29
|
+
}
|
|
30
|
+
interface ThreadInfo { id: string; kind: "direct" | "group"; title?: string; auto?: boolean; repoKey?: string; participantIds: string[]; participants: AgentInfo[]; lastMessage?: BoardMessage | null; }
|
|
31
|
+
interface Coordination { key?: string; coordinator?: string | null; reports?: string[]; repoThread?: string; }
|
|
32
|
+
interface BoardMessage { id: string; threadId: string; senderType: "user" | "agent"; senderId: string; senderAlias?: string; text: string; priority: Priority; createdAt: number; }
|
|
33
|
+
interface GitInfo { branch?: string; repo?: string; commit?: string; commitTime?: number; commitSubject?: string; dirty?: boolean; }
|
|
34
|
+
|
|
35
|
+
type Listener = (event: any) => void;
|
|
36
|
+
|
|
37
|
+
function configDir(): string { return process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent"); }
|
|
38
|
+
function loadConfig(): Config | undefined {
|
|
39
|
+
// Unified store first; the standalone agent-board.json is still honoured.
|
|
40
|
+
const url = env("AGENT_BOARD_URL");
|
|
41
|
+
const token = env("AGENT_BOARD_TOKEN");
|
|
42
|
+
if (url && token) return { url, token, adminName: env("AGENT_BOARD_NAME") };
|
|
43
|
+
try {
|
|
44
|
+
const value = JSON.parse(fs.readFileSync(path.join(configDir(), "agent-board.json"), "utf8"));
|
|
45
|
+
if (typeof value.url === "string" && typeof value.token === "string") return value;
|
|
46
|
+
} catch {}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
function cleanLine(value: unknown, max = 140): string {
|
|
50
|
+
const text = String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
51
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
52
|
+
}
|
|
53
|
+
function git(cwd: string, args: string[]): string | undefined {
|
|
54
|
+
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", timeout: 1_000, windowsHide: true });
|
|
55
|
+
return result.status === 0 ? result.stdout.trim() || undefined : undefined;
|
|
56
|
+
}
|
|
57
|
+
function normalizeRemote(remote?: string): string | undefined {
|
|
58
|
+
if (!remote) return undefined;
|
|
59
|
+
return remote.replace(/^git@([^:]+):/, "https://$1/").replace(/\.git$/, "").toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
function readGit(cwd: string): GitInfo {
|
|
62
|
+
const meta = git(cwd, ["show", "-s", "--format=%H%n%ct%n%s", "HEAD"])?.split("\n") ?? [];
|
|
63
|
+
return {
|
|
64
|
+
branch: git(cwd, ["branch", "--show-current"]),
|
|
65
|
+
repo: normalizeRemote(git(cwd, ["remote", "get-url", "origin"])) ?? git(cwd, ["rev-parse", "--show-toplevel"]),
|
|
66
|
+
commit: meta[0],
|
|
67
|
+
commitTime: meta[1] ? Number(meta[1]) * 1_000 : undefined,
|
|
68
|
+
commitSubject: meta[2],
|
|
69
|
+
dirty: Boolean(git(cwd, ["status", "--porcelain"])),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
class BoardClient {
|
|
74
|
+
private ws?: WebSocket;
|
|
75
|
+
private stopped = false;
|
|
76
|
+
private reconnect?: ReturnType<typeof setTimeout>;
|
|
77
|
+
private heartbeat?: ReturnType<typeof setInterval>;
|
|
78
|
+
private attempts = 0;
|
|
79
|
+
private pending = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
80
|
+
private listeners = new Set<Listener>();
|
|
81
|
+
private agent?: AgentInfo;
|
|
82
|
+
connected = false;
|
|
83
|
+
coordination: Coordination = {};
|
|
84
|
+
|
|
85
|
+
constructor(private readonly config: Config) {}
|
|
86
|
+
start(agent: AgentInfo): void { this.agent = agent; this.stopped = false; this.connect(); }
|
|
87
|
+
stop(): void {
|
|
88
|
+
this.stopped = true; this.connected = false;
|
|
89
|
+
if (this.reconnect) clearTimeout(this.reconnect);
|
|
90
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
91
|
+
this.ws?.close(1000, "pi session shutdown"); this.ws = undefined;
|
|
92
|
+
for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(new Error("Agent board disconnected")); }
|
|
93
|
+
this.pending.clear();
|
|
94
|
+
}
|
|
95
|
+
on(listener: Listener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
96
|
+
private emit(event: any): void { for (const listener of this.listeners) { try { listener(event); } catch {} } }
|
|
97
|
+
private connect(): void {
|
|
98
|
+
if (this.stopped || !this.agent) return;
|
|
99
|
+
const ws = new WebSocket(this.config.url);
|
|
100
|
+
this.ws = ws;
|
|
101
|
+
ws.on("open", () => ws.send(JSON.stringify({ t: "register", token: this.config.token, agent: this.agent })));
|
|
102
|
+
ws.on("message", (raw) => {
|
|
103
|
+
let value: any; try { value = JSON.parse(raw.toString()); } catch { return; }
|
|
104
|
+
if (value.t === "registered") {
|
|
105
|
+
this.connected = true; this.attempts = 0;
|
|
106
|
+
this.coordination = { key: value.key, coordinator: value.coordinator, reports: value.reports, repoThread: value.repoThread };
|
|
107
|
+
this.emit({ t: "connection", connected: true });
|
|
108
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
109
|
+
this.heartbeat = setInterval(() => this.presence({}), HEARTBEAT_MS); this.heartbeat.unref?.();
|
|
110
|
+
} else if (value.t === "coordination") {
|
|
111
|
+
this.coordination = { ...this.coordination, key: value.key, coordinator: value.coordinator, reports: value.reports };
|
|
112
|
+
this.emit(value);
|
|
113
|
+
} else if (value.t === "thread") {
|
|
114
|
+
this.emit(value);
|
|
115
|
+
} else if (value.t === "res") {
|
|
116
|
+
const item = this.pending.get(value.id); if (!item) return;
|
|
117
|
+
this.pending.delete(value.id); clearTimeout(item.timer);
|
|
118
|
+
if (value.ok) item.resolve(value.data);
|
|
119
|
+
else item.reject(new Error(value.error || "Board request failed"));
|
|
120
|
+
} else if (value.t === "message") this.emit(value);
|
|
121
|
+
});
|
|
122
|
+
const disconnected = () => {
|
|
123
|
+
if (this.ws !== ws) return;
|
|
124
|
+
this.connected = false; this.emit({ t: "connection", connected: false });
|
|
125
|
+
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
126
|
+
if (!this.stopped) {
|
|
127
|
+
const delay = Math.min(15_000, 500 * 2 ** Math.min(this.attempts++, 5));
|
|
128
|
+
this.reconnect = setTimeout(() => this.connect(), delay); this.reconnect.unref?.();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
ws.once("close", disconnected); ws.once("error", disconnected);
|
|
132
|
+
}
|
|
133
|
+
presence(patch: Partial<AgentInfo>): void {
|
|
134
|
+
if (!this.agent) return;
|
|
135
|
+
this.agent = { ...this.agent, ...patch };
|
|
136
|
+
if (this.ws?.readyState === WebSocket.OPEN && this.connected) this.ws.send(JSON.stringify({ t: "presence", agent: patch }));
|
|
137
|
+
}
|
|
138
|
+
activity(type: string, summary: string): void {
|
|
139
|
+
if (this.ws?.readyState === WebSocket.OPEN && this.connected) this.ws.send(JSON.stringify({ t: "activity", event: { type, summary: cleanLine(summary), at: Date.now() } }));
|
|
140
|
+
}
|
|
141
|
+
request(action: string, input: Record<string, unknown> = {}, timeout = REQUEST_TIMEOUT_MS): Promise<any> {
|
|
142
|
+
if (!this.connected || this.ws?.readyState !== WebSocket.OPEN) return Promise.reject(new Error("Agent board is not connected"));
|
|
143
|
+
const id = randomUUID();
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error("Agent board request timed out")); }, timeout);
|
|
146
|
+
timer.unref?.(); this.pending.set(id, { resolve, reject, timer });
|
|
147
|
+
this.ws!.send(JSON.stringify({ t: "req", id, action, ...input }));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class BoardView implements Component, Focusable {
|
|
153
|
+
private input = new Input();
|
|
154
|
+
private threads: ThreadInfo[] = [];
|
|
155
|
+
private messages: BoardMessage[] = [];
|
|
156
|
+
private selected = 0;
|
|
157
|
+
private sending = false;
|
|
158
|
+
private error?: string;
|
|
159
|
+
private initialRecipients?: string[];
|
|
160
|
+
private subscribedThread?: string;
|
|
161
|
+
private unsubscribe: () => void;
|
|
162
|
+
private _focused = false;
|
|
163
|
+
get focused(): boolean { return this._focused; }
|
|
164
|
+
set focused(value: boolean) { this._focused = value; this.input.focused = value; }
|
|
165
|
+
|
|
166
|
+
constructor(
|
|
167
|
+
private readonly client: BoardClient,
|
|
168
|
+
private readonly tui: any,
|
|
169
|
+
private readonly theme: any,
|
|
170
|
+
private readonly done: () => void,
|
|
171
|
+
recipients: string[],
|
|
172
|
+
) {
|
|
173
|
+
this.initialRecipients = recipients.length ? recipients : undefined;
|
|
174
|
+
this.input.onSubmit = (value) => void this.send(value, "normal");
|
|
175
|
+
this.input.onEscape = () => this.close();
|
|
176
|
+
this.unsubscribe = client.on((event) => {
|
|
177
|
+
if (event.t === "connection") {
|
|
178
|
+
this.error = event.connected ? undefined : "Messaging Board is offline, reconnecting…";
|
|
179
|
+
if (event.connected) void this.load();
|
|
180
|
+
this.tui.requestRender();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (event.t !== "message") return;
|
|
184
|
+
const index = this.threads.findIndex((thread) => thread.id === event.thread.id);
|
|
185
|
+
if (index < 0) this.threads.unshift(event.thread); else this.threads[index] = event.thread;
|
|
186
|
+
if (this.current()?.id === event.message.threadId) this.messages.push(event.message);
|
|
187
|
+
this.tui.requestRender();
|
|
188
|
+
});
|
|
189
|
+
void this.load();
|
|
190
|
+
}
|
|
191
|
+
private current(): ThreadInfo | undefined { return this.threads[this.selected]; }
|
|
192
|
+
private async load(): Promise<void> {
|
|
193
|
+
try {
|
|
194
|
+
this.threads = await this.client.request("threads", { actor: "user" });
|
|
195
|
+
if (this.threads.length) await this.loadMessages();
|
|
196
|
+
} catch (error) { this.error = (error as Error).message; }
|
|
197
|
+
this.tui.requestRender();
|
|
198
|
+
}
|
|
199
|
+
private async loadMessages(): Promise<void> {
|
|
200
|
+
const thread = this.current();
|
|
201
|
+
if (!thread) { this.messages = []; return; }
|
|
202
|
+
try {
|
|
203
|
+
const previous = this.subscribedThread;
|
|
204
|
+
this.subscribedThread = thread.id;
|
|
205
|
+
await this.client.request("subscribe", { actor: "user", thread: thread.id, previous });
|
|
206
|
+
this.messages = (await this.client.request("messages", { actor: "user", thread: thread.id, limit: 100 })).messages;
|
|
207
|
+
} catch (error) { this.error = (error as Error).message; }
|
|
208
|
+
}
|
|
209
|
+
private async send(value: string, priority: Priority): Promise<void> {
|
|
210
|
+
const text = value.trim(); if (!text || this.sending) return;
|
|
211
|
+
this.sending = true; this.error = undefined;
|
|
212
|
+
try {
|
|
213
|
+
const result = await this.client.request("send", {
|
|
214
|
+
actor: "user", thread: this.current()?.id, recipients: this.current() ? undefined : this.initialRecipients,
|
|
215
|
+
message: text.slice(0, MESSAGE_MAX), priority,
|
|
216
|
+
});
|
|
217
|
+
if (!this.current()) {
|
|
218
|
+
this.threads.unshift(result.thread); this.selected = 0; this.initialRecipients = undefined;
|
|
219
|
+
await this.client.request("subscribe", { actor: "user", thread: result.thread.id, previous: this.subscribedThread });
|
|
220
|
+
this.subscribedThread = result.thread.id;
|
|
221
|
+
}
|
|
222
|
+
this.messages.push(result.message); this.input.setValue("");
|
|
223
|
+
} catch (error) { this.error = (error as Error).message; }
|
|
224
|
+
finally { this.sending = false; this.tui.requestRender(); }
|
|
225
|
+
}
|
|
226
|
+
private close(): void {
|
|
227
|
+
this.unsubscribe();
|
|
228
|
+
if (this.subscribedThread) void this.client.request("subscribe", { actor: "user", previous: this.subscribedThread }).catch(() => {});
|
|
229
|
+
this.done();
|
|
230
|
+
}
|
|
231
|
+
handleInput(data: string): void {
|
|
232
|
+
if (matchesKey(data, Key.escape)) return this.close();
|
|
233
|
+
if (matchesKey(data, Key.tab) && this.threads.length > 1) {
|
|
234
|
+
this.selected = (this.selected + 1) % this.threads.length; void this.loadMessages(); this.tui.requestRender(); return;
|
|
235
|
+
}
|
|
236
|
+
if (matchesKey(data, Key.ctrl("enter"))) {
|
|
237
|
+
const value = this.input.getValue();
|
|
238
|
+
if (value) void this.send(value, "urgent");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
this.input.handleInput(data); this.tui.requestRender();
|
|
242
|
+
}
|
|
243
|
+
invalidate(): void { this.input.invalidate(); }
|
|
244
|
+
render(width: number): string[] {
|
|
245
|
+
const w = Math.max(32, width);
|
|
246
|
+
const inner = Math.max(1, w - 4);
|
|
247
|
+
const th = this.theme;
|
|
248
|
+
const status = this.client.connected
|
|
249
|
+
? `${th.fg("success", "●")} ${th.fg("success", th.bold("Active"))}`
|
|
250
|
+
: `${th.fg("error", "●")} ${th.fg("error", th.bold("Offline"))}`;
|
|
251
|
+
const tabs = this.threads.length
|
|
252
|
+
? this.threads.slice(0, 6).map((item, index) => {
|
|
253
|
+
const label = ` ${index + 1} ${item.title || item.id.slice(0, 8)} `;
|
|
254
|
+
return index === this.selected
|
|
255
|
+
? th.bg("selectedBg", th.fg("accent", th.bold(label)))
|
|
256
|
+
: th.fg("muted", label);
|
|
257
|
+
}).join(th.fg("dim", "│"))
|
|
258
|
+
: th.fg("dim", this.initialRecipients ? ` New chat: ${this.initialRecipients.join(", ")} ` : " No chats. Use /board <agent> to start one ");
|
|
259
|
+
const messageLines: string[] = [];
|
|
260
|
+
for (const message of this.messages) {
|
|
261
|
+
const time = new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
262
|
+
const sender = message.senderType === "user" ? "you" : (message.senderAlias || message.senderId.slice(0, 8));
|
|
263
|
+
const marker = message.priority === "urgent" ? th.fg("warning", "!") : th.fg("dim", "›");
|
|
264
|
+
const label = `${marker} ${message.senderType === "user" ? th.fg("success", th.bold(sender)) : th.fg("accent", th.bold(sender))} ${th.fg("dim", time)}`;
|
|
265
|
+
messageLines.push(label);
|
|
266
|
+
for (const line of wrapTextWithAnsi(message.text, Math.max(10, inner - 4))) messageLines.push(` ${line}`);
|
|
267
|
+
messageLines.push("");
|
|
268
|
+
}
|
|
269
|
+
if (!messageLines.length) messageLines.push(th.fg("dim", "No messages yet."));
|
|
270
|
+
const popupHeight = Math.max(12, Math.min((this.tui.terminal?.rows ?? 30) - 6, 30));
|
|
271
|
+
const bodyHeight = Math.max(3, popupHeight - 8 - (this.error ? 1 : 0));
|
|
272
|
+
const visibleMessages = messageLines.slice(-bodyHeight);
|
|
273
|
+
while (visibleMessages.length < bodyHeight) visibleMessages.unshift("");
|
|
274
|
+
const inputLine = this.input.render(Math.max(1, inner - 11))[0] ?? "";
|
|
275
|
+
const content = [
|
|
276
|
+
`${th.fg("accent", th.bold("Messaging Board"))} ${status} ${th.fg("dim", `· ${this.threads.length} chat${this.threads.length === 1 ? "" : "s"}`)}`,
|
|
277
|
+
tabs,
|
|
278
|
+
th.fg("dim", "─".repeat(inner)),
|
|
279
|
+
...visibleMessages,
|
|
280
|
+
th.fg("dim", "─".repeat(inner)),
|
|
281
|
+
...(this.error ? [th.fg("error", this.error)] : []),
|
|
282
|
+
`${th.fg("accent", th.bold("Message"))} ${inputLine}`,
|
|
283
|
+
th.fg("dim", "enter send · tab next chat · esc close"),
|
|
284
|
+
];
|
|
285
|
+
const row = (value: string): string => {
|
|
286
|
+
const fitted = truncateToWidth(value, inner, "");
|
|
287
|
+
return `${th.fg("border", "│")} ${fitted}${" ".repeat(Math.max(0, inner - visibleWidth(fitted)))} ${th.fg("border", "│")}`;
|
|
288
|
+
};
|
|
289
|
+
return [
|
|
290
|
+
th.fg("border", `╭${"─".repeat(Math.max(0, w - 2))}╮`),
|
|
291
|
+
...content.map(row),
|
|
292
|
+
th.fg("border", `╰${"─".repeat(Math.max(0, w - 2))}╯`),
|
|
293
|
+
];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export default function (pi: ExtensionAPI) {
|
|
298
|
+
const config = loadConfig();
|
|
299
|
+
if (!config) return;
|
|
300
|
+
const client = new BoardClient(config);
|
|
301
|
+
let ctx: ExtensionContext | undefined;
|
|
302
|
+
let self: AgentInfo | undefined;
|
|
303
|
+
let gitInfo: GitInfo = {};
|
|
304
|
+
let snapshot: { at: number; agents: AgentInfo[] } = { at: 0, agents: [] };
|
|
305
|
+
|
|
306
|
+
const updateStatus = () => {
|
|
307
|
+
if (!ctx?.hasUI) return;
|
|
308
|
+
const color = client.connected ? "success" : "error";
|
|
309
|
+
const label = client.connected ? "Active" : "Offline";
|
|
310
|
+
ctx.ui.setStatus("agent-board", ctx.ui.theme.fg(color, `● Messaging Board ${label}`));
|
|
311
|
+
};
|
|
312
|
+
const refreshGit = () => {
|
|
313
|
+
if (!ctx || !self) return;
|
|
314
|
+
gitInfo = readGit(ctx.cwd);
|
|
315
|
+
self = { ...self, ...gitInfo };
|
|
316
|
+
client.presence(gitInfo);
|
|
317
|
+
};
|
|
318
|
+
const formatCoordination = (): string[] => {
|
|
319
|
+
const { coordinator, reports, repoThread } = client.coordination;
|
|
320
|
+
const lines: string[] = [];
|
|
321
|
+
if (coordinator) lines.push(`You report to coordinator '${coordinator}'. Use agent_board action 'report' to send them progress, blockers, and completed work.`);
|
|
322
|
+
if (reports?.length) lines.push(`You are the coordinator for: ${reports.join(", ")}. Their reports arrive as agent-board messages; keep their work aligned.`);
|
|
323
|
+
if (repoThread) lines.push(`Shared repo room: thread '${repoThread}' automatically contains every agent working in this repository. Post repo-wide notices there with agent_board send.`);
|
|
324
|
+
return lines;
|
|
325
|
+
};
|
|
326
|
+
const formatSnapshot = (agents: AgentInfo[]): string => {
|
|
327
|
+
const coordinationLines = formatCoordination();
|
|
328
|
+
if (!agents.length) return ["[Live agent board]\nNo other Pi agents are currently running.", ...coordinationLines].join("\n");
|
|
329
|
+
const lines = agents.slice(0, 20).map((agent) => {
|
|
330
|
+
const commit = agent.commit ? agent.commit.slice(0, 12) : "no-commit";
|
|
331
|
+
const sameLine = agent.repo && gitInfo.repo === agent.repo && agent.branch === gitInfo.branch;
|
|
332
|
+
const divergent = sameLine && agent.commit && gitInfo.commit && agent.commit !== gitInfo.commit ? " ⚠ different commit" : "";
|
|
333
|
+
const activity = agent.state === "tool" && agent.lastTool ? agent.lastTool : agent.state;
|
|
334
|
+
const role = agent.reports?.length ? " [coordinator]" : agent.coordinator ? ` [reports to ${agent.coordinator}]` : "";
|
|
335
|
+
return `- ${agent.alias || agent.sessionId.slice(0, 8)}@${agent.host}${role}: ${activity}; ${agent.branch || "-"}@${commit}${divergent}${agent.lastPrompt ? `; ${agent.lastPrompt}` : ""}`;
|
|
336
|
+
});
|
|
337
|
+
return [
|
|
338
|
+
`[Live agent board, consult before working]`,
|
|
339
|
+
`Your commit: ${gitInfo.branch || "-"}@${gitInfo.commit?.slice(0, 12) || "no-commit"}`,
|
|
340
|
+
`Running collaborators:`,
|
|
341
|
+
lines.join("\n"),
|
|
342
|
+
...coordinationLines,
|
|
343
|
+
`Avoid duplicate or outdated work. Use agent_board to coordinate when useful.`,
|
|
344
|
+
].join("\n");
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
client.on((event) => {
|
|
348
|
+
if (event.t === "connection" || event.t === "coordination") { updateStatus(); return; }
|
|
349
|
+
if (event.t !== "message" || event.adminView || !ctx) return;
|
|
350
|
+
const message = event.message as BoardMessage;
|
|
351
|
+
const thread = event.thread as ThreadInfo;
|
|
352
|
+
const content = `[Live agent-board message${message.priority === "urgent" ? " URGENT" : ""}]\nFrom: ${message.senderAlias || message.senderId}\nThread: ${thread.title || thread.id}\nCommit context: you are on ${gitInfo.branch || "-"}@${gitInfo.commit?.slice(0, 12) || "no-commit"}\n\n${message.text}\n\nEvaluate this at the next decision point. If relevant, adjust your work and reply through agent_board with thread '${thread.id}'. If irrelevant, continue the current task.`;
|
|
353
|
+
if (message.priority === "urgent" && !ctx.isIdle()) ctx.abort();
|
|
354
|
+
pi.sendMessage({ customType: "agent-board", content, display: true, details: { message, thread } }, { deliverAs: "steer", triggerTurn: true });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
pi.on("session_start", async (_event, eventCtx) => {
|
|
358
|
+
if (!eventCtx.hasUI) return;
|
|
359
|
+
ctx = eventCtx; gitInfo = readGit(eventCtx.cwd);
|
|
360
|
+
self = {
|
|
361
|
+
sessionId: eventCtx.sessionManager.getSessionId(), alias: pi.getSessionName(), host: os.hostname(), cwd: eventCtx.cwd,
|
|
362
|
+
model: eventCtx.model ? `${eventCtx.model.provider}/${eventCtx.model.id}` : undefined, state: "idle", ...gitInfo,
|
|
363
|
+
};
|
|
364
|
+
client.start(self); updateStatus();
|
|
365
|
+
});
|
|
366
|
+
pi.on("session_info_changed", async (event) => { if (self) { self.alias = event.name; client.presence({ alias: event.name }); } });
|
|
367
|
+
pi.on("model_select", async (event) => client.presence({ model: `${event.model.provider}/${event.model.id}` }));
|
|
368
|
+
pi.on("before_agent_start", async (event) => {
|
|
369
|
+
client.presence({ state: "thinking", lastPrompt: cleanLine(event.prompt) });
|
|
370
|
+
client.activity("prompt", event.prompt);
|
|
371
|
+
});
|
|
372
|
+
pi.on("tool_execution_start", async (event) => {
|
|
373
|
+
const lastTool = cleanLine(`${event.toolName}: ${JSON.stringify(event.args)}`, 100);
|
|
374
|
+
client.presence({ state: "tool", lastTool }); client.activity("tool", lastTool);
|
|
375
|
+
});
|
|
376
|
+
pi.on("tool_execution_end", async () => client.presence({ state: "thinking" }));
|
|
377
|
+
pi.on("agent_settled", async () => { client.presence({ state: "idle", lastTool: undefined }); refreshGit(); });
|
|
378
|
+
pi.on("session_shutdown", async (_event, eventCtx) => { client.stop(); eventCtx.ui.setStatus("agent-board", undefined); ctx = undefined; self = undefined; });
|
|
379
|
+
|
|
380
|
+
// A transient, late context message avoids growing the session and keeps the static prompt cache stable.
|
|
381
|
+
pi.on("context", async (event) => {
|
|
382
|
+
if (!client.connected) return;
|
|
383
|
+
try {
|
|
384
|
+
if (Date.now() - snapshot.at > SNAPSHOT_CACHE_MS) snapshot = { at: Date.now(), agents: await client.request("agents", {}, 500) };
|
|
385
|
+
return {
|
|
386
|
+
messages: [...event.messages, {
|
|
387
|
+
role: "custom" as const, customType: "agent-board-snapshot", content: formatSnapshot(snapshot.agents),
|
|
388
|
+
display: false, details: {}, timestamp: Date.now(),
|
|
389
|
+
}],
|
|
390
|
+
};
|
|
391
|
+
} catch { return; }
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
pi.registerMessageRenderer("agent-board", (message, _options, theme) => new Text(theme.fg("accent", message.content as string), 1, 0));
|
|
395
|
+
|
|
396
|
+
registerBoardLifecycle(pi);
|
|
397
|
+
|
|
398
|
+
pi.registerCommand("board", {
|
|
399
|
+
description: "Live agent board, or setup | restart | clear | status",
|
|
400
|
+
getArgumentCompletions: (prefix) =>
|
|
401
|
+
["setup", "restart", "clear", "status"]
|
|
402
|
+
.filter((option) => option.startsWith(prefix))
|
|
403
|
+
.map((option) => ({ value: option, label: option })),
|
|
404
|
+
handler: async (args, commandCtx) => {
|
|
405
|
+
// Admin verbs run without the TUI; anything else opens the board view.
|
|
406
|
+
if (await handleBoardAdmin(pi, commandCtx, args)) return;
|
|
407
|
+
if (commandCtx.mode !== "tui") { commandCtx.ui.notify("The Messaging Board UI requires TUI mode.", "warning"); return; }
|
|
408
|
+
const recipients = args.split(/[ ,]+/).map((value) => value.trim()).filter(Boolean);
|
|
409
|
+
await commandCtx.ui.custom<void>(
|
|
410
|
+
(tui, theme, _keys, done) => new BoardView(client, tui, theme, done, recipients),
|
|
411
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "86%", minWidth: 56, maxHeight: "85%", margin: 1 } },
|
|
412
|
+
);
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
pi.registerTool({
|
|
417
|
+
name: "agent_board",
|
|
418
|
+
label: "Agent Board",
|
|
419
|
+
description: "Consult and message all currently running local and remote Pi agents. Every agent record includes host, state, Git branch, and commit hash. Messages are live: idle agents wake immediately and busy agents see them at the next safe turn boundary. Stopped agents are never listed and messages are not queued for them. Agents working in the same repository share an auto-created repo room, and coordinators can be assigned so subordinate agents report their progress upward.",
|
|
420
|
+
promptSnippet: "Consult live collaborators, report to your coordinator, and exchange direct, repo-room, or group messages",
|
|
421
|
+
promptGuidelines: [
|
|
422
|
+
"Consult the transient Live agent board context before starting work; avoid duplicating work and compare Git commit hashes before relying on another agent's changes.",
|
|
423
|
+
"Use agent_board to inform collaborators when their commit or assumptions appear outdated, and reply only when coordination is useful.",
|
|
424
|
+
"If the board snapshot names a coordinator for you, use agent_board action 'report' to send them meaningful progress, blockers, and completed work instead of staying silent.",
|
|
425
|
+
"Use agent_board action 'set_coordinator' when the user puts one agent in charge of others, and 'coordinators' to inspect the current reporting structure.",
|
|
426
|
+
"Post repo-wide notices in the shared repo room thread so every agent in the same repository sees them.",
|
|
427
|
+
],
|
|
428
|
+
parameters: Type.Object({
|
|
429
|
+
action: StringEnum(["agents", "inspect", "threads", "read", "send", "report", "coordinators", "set_coordinator"] as const),
|
|
430
|
+
agent: Type.Optional(Type.String({ description: "For inspect: session id prefix, alias, or cwd substring. For set_coordinator: the coordinator's alias, or 'none' to clear" })),
|
|
431
|
+
thread: Type.Optional(Type.String({ description: "Thread id for read/send/reply" })),
|
|
432
|
+
recipients: Type.Optional(Type.Array(Type.String(), { description: "Active agent aliases or ids for a new direct/group chat; for set_coordinator, the agents that should report to the coordinator (defaults to yourself)" })),
|
|
433
|
+
message: Type.Optional(Type.String({ description: "Message text for send or report" })),
|
|
434
|
+
priority: Type.Optional(StringEnum(["normal", "urgent"] as const)),
|
|
435
|
+
limit: Type.Optional(Type.Number({ description: "Maximum messages/activity events to return" })),
|
|
436
|
+
}),
|
|
437
|
+
async execute(_id, params) {
|
|
438
|
+
const action = params.action === "read" ? "messages" : params.action;
|
|
439
|
+
const { action: _requestedAction, ...input } = params;
|
|
440
|
+
const data = await client.request(action, { ...input, actor: "agent" });
|
|
441
|
+
let text: string;
|
|
442
|
+
if (params.action === "agents") {
|
|
443
|
+
const agents = data as AgentInfo[];
|
|
444
|
+
text = agents.length ? agents.map((agent) => `${agent.sessionId.slice(0, 8)} ${agent.alias || "(unnamed)"}\n ${agent.host} · ${agent.cwd}\n ${agent.state}${agent.lastTool ? ` · ${agent.lastTool}` : ""}\n git ${agent.branch || "-"}@${agent.commit?.slice(0, 12) || "no-commit"}${agent.commitSubject ? ` · ${agent.commitSubject}` : ""}`).join("\n") : "No other Pi agents are currently running.";
|
|
445
|
+
} else if (params.action === "send" || params.action === "report") {
|
|
446
|
+
text = `Delivered to: ${data.delivered.join(", ") || "none"}.${data.notRunning.length ? ` Not running: ${data.notRunning.join(", ")}.` : ""} Thread: ${data.thread.id}`;
|
|
447
|
+
} else if (params.action === "set_coordinator") {
|
|
448
|
+
text = data.applied.length
|
|
449
|
+
? data.applied.map((item: any) => item.coordinator ? `${item.agent} now reports to ${item.coordinator}` : `${item.agent} no longer has a coordinator`).join("\n")
|
|
450
|
+
: "No agents matched; nothing changed.";
|
|
451
|
+
} else if (params.action === "coordinators") {
|
|
452
|
+
const links = (data.links as any[]).map((link) => `${link.agent}${link.agentOnline ? "" : " (offline)"} → ${link.coordinator}${link.coordinatorOnline ? "" : " (offline)"}`);
|
|
453
|
+
text = [
|
|
454
|
+
data.self.coordinator ? `You report to: ${data.self.coordinator}` : "You have no coordinator.",
|
|
455
|
+
data.self.reports.length ? `Reporting to you: ${data.self.reports.join(", ")}` : "No agents report to you.",
|
|
456
|
+
links.length ? `\nAll links:\n${links.join("\n")}` : "\nNo coordinator links are configured.",
|
|
457
|
+
].join("\n");
|
|
458
|
+
} else text = JSON.stringify(data, null, 2);
|
|
459
|
+
return { content: [{ type: "text", text }], details: data };
|
|
460
|
+
},
|
|
461
|
+
});
|
|
462
|
+
}
|