@jameslovespancakes/pi-plus 1.0.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/LICENSE +21 -0
- package/README.md +190 -0
- package/config/pi-plus.example.json +60 -0
- package/config/skills/model-routing/SKILL.md +86 -0
- package/images/board_demo.png +0 -0
- package/images/pi-plus.svg +10 -0
- package/images/pi-plus_demo.png +0 -0
- package/images/provider_demo.png +0 -0
- package/images/remote_demo.png +0 -0
- package/images/usage_demo.png +0 -0
- package/package.json +67 -0
- package/server/board-server.mjs +641 -0
- package/server/package.json +17 -0
- package/src/core/accounts/registry.ts +93 -0
- package/src/core/anthropic/client-identity.ts +241 -0
- package/src/core/anthropic/models.ts +69 -0
- package/src/core/anthropic/oauth.ts +208 -0
- package/src/core/anthropic/quota.ts +253 -0
- package/src/core/anthropic/routing.ts +168 -0
- package/src/core/anthropic/store.ts +225 -0
- package/src/core/anthropic/vendor/README.md +36 -0
- package/src/core/anthropic/vendor/xxhash-wasm.LICENSE.md +25 -0
- package/src/core/anthropic/vendor/xxhash-wasm.js +2 -0
- package/src/core/anthropic/xxhash64.ts +33 -0
- package/src/core/catalog/quality.ts +314 -0
- package/src/core/codex/oauth.ts +129 -0
- package/src/core/codex/quota.ts +88 -0
- package/src/core/codex/store.ts +97 -0
- package/src/core/config.ts +169 -0
- package/src/core/env.ts +58 -0
- package/src/core/exec/process.ts +146 -0
- package/src/core/exec/ssh-config.ts +157 -0
- package/src/core/oauth/pkce.ts +88 -0
- package/src/core/policy/policy.ts +183 -0
- package/src/core/quota/pool.ts +64 -0
- package/src/core/quota/usage-source.ts +289 -0
- package/src/core/store.ts +43 -0
- package/src/domains/agents/board-setup.ts +409 -0
- package/src/domains/agents/index.ts +462 -0
- package/src/domains/models/catalog-tool.ts +361 -0
- package/src/domains/models/index.ts +14 -0
- package/src/domains/models/policy-gate.ts +169 -0
- package/src/domains/models/provider-picker.ts +208 -0
- package/src/domains/remote/config-path.ts +41 -0
- package/src/domains/remote/index.ts +866 -0
- package/src/domains/remote/setup.ts +425 -0
- package/src/domains/setup/index.ts +220 -0
- package/src/domains/subscriptions/accounts-picker.ts +178 -0
- package/src/domains/subscriptions/accounts.ts +242 -0
- package/src/domains/subscriptions/footer.ts +182 -0
- package/src/domains/subscriptions/index.ts +42 -0
- package/src/domains/subscriptions/provider.ts +219 -0
- package/src/domains/subscriptions/providers/anthropic.ts +149 -0
- package/src/domains/subscriptions/providers/codex.ts +148 -0
- package/src/domains/subscriptions/routing.ts +72 -0
- package/src/services/usage-service.ts +186 -0
- package/src/ui/format.ts +73 -0
- package/src/ui/usage-bars.ts +154 -0
- package/src/vendor/anthropic.ts +109 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* pi-plus agent board server.
|
|
11
|
+
*
|
|
12
|
+
* Single file, one dependency (`ws`), SQLite via node's built-in driver.
|
|
13
|
+
* Designed to sit idle at a few MB: every statement is prepared once, the
|
|
14
|
+
* per-agent activity log is a fixed ring, and housekeeping is set-based SQL
|
|
15
|
+
* rather than per-row loops.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const PORT = Number(process.env.AGENT_BOARD_PORT ?? 8787);
|
|
19
|
+
const TOKEN = process.env.AGENT_BOARD_TOKEN;
|
|
20
|
+
const DB_PATH = resolve(process.env.AGENT_BOARD_DB ?? "./data/board.sqlite");
|
|
21
|
+
const MAX_MESSAGE = Number(process.env.AGENT_BOARD_MAX_MESSAGE ?? 8_000);
|
|
22
|
+
/** Auto repo rooms and ad-hoc groups keep a rolling window of traffic. */
|
|
23
|
+
const GROUP_TTL_MS = Number(process.env.AGENT_BOARD_GROUP_TTL_MS ?? 5 * 24 * 60 * 60 * 1_000);
|
|
24
|
+
/** A direct thread is discarded once every participant has been gone this long. */
|
|
25
|
+
const DIRECT_GRACE_MS = Number(process.env.AGENT_BOARD_DIRECT_GRACE_MS ?? 120_000);
|
|
26
|
+
const PURGE_INTERVAL_MS = Number(process.env.AGENT_BOARD_PURGE_INTERVAL_MS ?? 60_000);
|
|
27
|
+
const ACTIVITY_RING = 48;
|
|
28
|
+
|
|
29
|
+
if (!TOKEN) throw new Error("AGENT_BOARD_TOKEN is required");
|
|
30
|
+
|
|
31
|
+
// Keep the message store readable only by the account that owns it.
|
|
32
|
+
process.umask(0o077);
|
|
33
|
+
|
|
34
|
+
/** The tailnet range (100.64.0.0/10) is the only remote interface the board may bind. */
|
|
35
|
+
function tailnetAddresses() {
|
|
36
|
+
const found = [];
|
|
37
|
+
for (const entries of Object.values(os.networkInterfaces())) {
|
|
38
|
+
for (const entry of entries ?? []) {
|
|
39
|
+
if (entry.family !== "IPv4" || entry.internal) continue;
|
|
40
|
+
const [a, b] = entry.address.split(".").map(Number);
|
|
41
|
+
if (a === 100 && b >= 64 && b <= 127) found.push(entry.address);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const HOSTS = process.env.AGENT_BOARD_HOST ? [process.env.AGENT_BOARD_HOST] : ["127.0.0.1", ...tailnetAddresses()];
|
|
48
|
+
|
|
49
|
+
/* ---------------------------------- store --------------------------------- */
|
|
50
|
+
|
|
51
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
52
|
+
const db = new DatabaseSync(DB_PATH);
|
|
53
|
+
|
|
54
|
+
db.exec(`
|
|
55
|
+
PRAGMA journal_mode=WAL;
|
|
56
|
+
PRAGMA foreign_keys=ON;
|
|
57
|
+
PRAGMA busy_timeout=5000;
|
|
58
|
+
PRAGMA synchronous=NORMAL;
|
|
59
|
+
-- Negative is KiB: cap the page cache instead of letting it grow unbounded.
|
|
60
|
+
PRAGMA cache_size=-2000;
|
|
61
|
+
CREATE TABLE IF NOT EXISTS threads (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
kind TEXT NOT NULL CHECK(kind IN ('direct','group')),
|
|
64
|
+
title TEXT,
|
|
65
|
+
participant_key TEXT NOT NULL UNIQUE,
|
|
66
|
+
created_at INTEGER NOT NULL,
|
|
67
|
+
auto INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
repo_key TEXT
|
|
69
|
+
);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS thread_participants (
|
|
71
|
+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
72
|
+
agent_id TEXT NOT NULL,
|
|
73
|
+
PRIMARY KEY(thread_id, agent_id)
|
|
74
|
+
);
|
|
75
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
76
|
+
id TEXT PRIMARY KEY,
|
|
77
|
+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
|
78
|
+
sender_type TEXT NOT NULL CHECK(sender_type IN ('user','agent')),
|
|
79
|
+
sender_id TEXT NOT NULL,
|
|
80
|
+
sender_alias TEXT,
|
|
81
|
+
text TEXT NOT NULL,
|
|
82
|
+
priority TEXT NOT NULL CHECK(priority IN ('normal','urgent')),
|
|
83
|
+
created_at INTEGER NOT NULL
|
|
84
|
+
);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS messages_thread_time ON messages(thread_id, created_at);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS coordinators (
|
|
87
|
+
agent_key TEXT PRIMARY KEY,
|
|
88
|
+
coordinator_key TEXT NOT NULL,
|
|
89
|
+
set_by TEXT,
|
|
90
|
+
set_at INTEGER NOT NULL
|
|
91
|
+
);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS coordinators_coordinator ON coordinators(coordinator_key);
|
|
93
|
+
CREATE TABLE IF NOT EXISTS agent_seen (
|
|
94
|
+
agent_id TEXT PRIMARY KEY,
|
|
95
|
+
agent_key TEXT,
|
|
96
|
+
alias TEXT,
|
|
97
|
+
last_seen INTEGER NOT NULL
|
|
98
|
+
);
|
|
99
|
+
`);
|
|
100
|
+
|
|
101
|
+
// Older databases predate these columns; adding them is a no-op once present.
|
|
102
|
+
for (const statement of [
|
|
103
|
+
"ALTER TABLE threads ADD COLUMN auto INTEGER NOT NULL DEFAULT 0",
|
|
104
|
+
"ALTER TABLE threads ADD COLUMN repo_key TEXT",
|
|
105
|
+
"ALTER TABLE messages ADD COLUMN sender_alias TEXT",
|
|
106
|
+
]) {
|
|
107
|
+
try {
|
|
108
|
+
db.exec(statement);
|
|
109
|
+
} catch { /* column already exists */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Prepared-statement cache. The previous implementation compiled SQL on every
|
|
114
|
+
* call: 36 separate `prepare()` sites, several inside per-row loops.
|
|
115
|
+
*/
|
|
116
|
+
const statements = new Map();
|
|
117
|
+
const q = (sql) => {
|
|
118
|
+
let statement = statements.get(sql);
|
|
119
|
+
if (!statement) statements.set(sql, (statement = db.prepare(sql)));
|
|
120
|
+
return statement;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/* --------------------------------- agents --------------------------------- */
|
|
124
|
+
|
|
125
|
+
/** @type {Map<string, {ws: WebSocket, agent: any, activity: any[], cursor: number, adminThreads: Set<string>}>} */
|
|
126
|
+
const active = new Map();
|
|
127
|
+
|
|
128
|
+
const agentKey = (agent) => agent?.key || agent?.alias || agent?.sessionId || "";
|
|
129
|
+
const liveEntry = (id) => (id ? active.get(id) : undefined);
|
|
130
|
+
const liveEntries = () => [...active.values()];
|
|
131
|
+
const liveByKey = (key) => liveEntries().find((entry) => agentKey(entry.agent) === key);
|
|
132
|
+
|
|
133
|
+
/** Fixed ring: writes never reallocate and memory per agent is bounded. */
|
|
134
|
+
function recordActivity(entry, event) {
|
|
135
|
+
entry.activity[entry.cursor % ACTIVITY_RING] = event;
|
|
136
|
+
entry.cursor += 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readActivity(entry, limit) {
|
|
140
|
+
const size = Math.min(entry.cursor, ACTIVITY_RING);
|
|
141
|
+
const take = Math.max(1, Math.min(limit, size));
|
|
142
|
+
const out = [];
|
|
143
|
+
for (let i = size - take; i < size; i += 1) {
|
|
144
|
+
out.push(entry.activity[(entry.cursor - size + i) % ACTIVITY_RING]);
|
|
145
|
+
}
|
|
146
|
+
return out.filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function publicAgent(entry) {
|
|
150
|
+
const seen = q("SELECT last_seen AS lastSeen FROM agent_seen WHERE agent_id=?").get(entry.agent.sessionId);
|
|
151
|
+
return { ...entry.agent, lastSeenAt: seen?.lastSeen ?? Date.now(), ...coordination(entry.agent) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function resolveAgent(needle) {
|
|
155
|
+
const text = String(needle ?? "").trim().toLowerCase();
|
|
156
|
+
if (!text) return undefined;
|
|
157
|
+
const entries = liveEntries();
|
|
158
|
+
const exact = entries.filter((e) => e.agent.sessionId === text || e.agent.alias?.toLowerCase() === text);
|
|
159
|
+
if (exact.length === 1) return exact[0];
|
|
160
|
+
const loose = entries.filter((e) =>
|
|
161
|
+
e.agent.sessionId.startsWith(text) || e.agent.alias?.toLowerCase().includes(text) || e.agent.cwd?.toLowerCase().includes(text));
|
|
162
|
+
return loose.length === 1 ? loose[0] : undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveKey(needle) {
|
|
166
|
+
const match = resolveAgent(needle);
|
|
167
|
+
if (match) return agentKey(match.agent);
|
|
168
|
+
const text = String(needle ?? "").trim();
|
|
169
|
+
if (!text) return undefined;
|
|
170
|
+
const row = q("SELECT agent_key AS key FROM agent_seen WHERE agent_key=? OR alias=? ORDER BY last_seen DESC LIMIT 1").get(text, text);
|
|
171
|
+
return row?.key;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function markSeen(agent) {
|
|
175
|
+
q(`INSERT INTO agent_seen(agent_id,agent_key,alias,last_seen) VALUES(?,?,?,?)
|
|
176
|
+
ON CONFLICT(agent_id) DO UPDATE SET agent_key=excluded.agent_key, alias=excluded.alias, last_seen=excluded.last_seen`)
|
|
177
|
+
.run(agent.sessionId, agentKey(agent), agent.alias ?? null, Date.now());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ------------------------------ coordination ------------------------------ */
|
|
181
|
+
|
|
182
|
+
const coordinatorOf = (key) =>
|
|
183
|
+
q("SELECT coordinator_key AS coordinatorKey, set_by AS setBy, set_at AS setAt FROM coordinators WHERE agent_key=?").get(key);
|
|
184
|
+
|
|
185
|
+
const reportsOf = (key) =>
|
|
186
|
+
q("SELECT agent_key AS agentKey FROM coordinators WHERE coordinator_key=? ORDER BY agent_key").all(key).map((r) => r.agentKey);
|
|
187
|
+
|
|
188
|
+
function coordination(agent) {
|
|
189
|
+
const key = agentKey(agent);
|
|
190
|
+
return { key, coordinator: coordinatorOf(key)?.coordinatorKey ?? null, reports: reportsOf(key) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function wouldCycle(agent, coordinator) {
|
|
194
|
+
const seen = new Set([agent]);
|
|
195
|
+
let cursor = coordinator;
|
|
196
|
+
while (cursor) {
|
|
197
|
+
if (seen.has(cursor)) return true;
|
|
198
|
+
seen.add(cursor);
|
|
199
|
+
cursor = coordinatorOf(cursor)?.coordinatorKey;
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* --------------------------------- threads -------------------------------- */
|
|
205
|
+
|
|
206
|
+
const threadRow = (id) =>
|
|
207
|
+
id ? q("SELECT id, kind, title, auto, repo_key AS repoKey, created_at AS createdAt FROM threads WHERE id=?").get(id) : undefined;
|
|
208
|
+
|
|
209
|
+
const participants = (threadId) =>
|
|
210
|
+
q("SELECT agent_id AS agentId FROM thread_participants WHERE thread_id=? ORDER BY agent_id").all(threadId).map((r) => r.agentId);
|
|
211
|
+
|
|
212
|
+
function offlineAgent(id) {
|
|
213
|
+
const seen = q("SELECT alias, last_seen AS lastSeen FROM agent_seen WHERE agent_id=?").get(id);
|
|
214
|
+
return { agent: { sessionId: id, alias: seen?.alias ?? undefined, state: "stopped", lastSeenAt: seen?.lastSeen }, offline: true };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function materializeThread(row) {
|
|
218
|
+
if (!row) return undefined;
|
|
219
|
+
const ids = participants(row.id);
|
|
220
|
+
const last = q(`SELECT text, sender_type AS senderType, sender_id AS senderId, sender_alias AS senderAlias, created_at AS createdAt
|
|
221
|
+
FROM messages WHERE thread_id=? ORDER BY created_at DESC LIMIT 1`).get(row.id);
|
|
222
|
+
return {
|
|
223
|
+
id: row.id,
|
|
224
|
+
kind: row.kind,
|
|
225
|
+
title: row.title ?? undefined,
|
|
226
|
+
auto: !!row.auto,
|
|
227
|
+
repoKey: row.repoKey ?? undefined,
|
|
228
|
+
participantIds: ids,
|
|
229
|
+
participants: ids.map((id) => liveEntry(id) ? publicAgent(liveEntry(id)) : offlineAgent(id).agent),
|
|
230
|
+
lastMessage: last ?? null,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function listThreads(actor, selfId) {
|
|
235
|
+
const rows = actor === "user"
|
|
236
|
+
? q("SELECT id, kind, title, auto, repo_key AS repoKey, created_at AS createdAt FROM threads ORDER BY auto DESC, created_at DESC").all()
|
|
237
|
+
: q(`SELECT t.id, t.kind, t.title, t.auto, t.repo_key AS repoKey, t.created_at AS createdAt
|
|
238
|
+
FROM threads t JOIN thread_participants p ON p.thread_id=t.id
|
|
239
|
+
WHERE p.agent_id=? ORDER BY t.auto DESC, t.created_at DESC`).all(selfId);
|
|
240
|
+
return rows.map(materializeThread);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function getOrCreateThread(agentIds, title) {
|
|
244
|
+
const ids = [...new Set(agentIds)].sort();
|
|
245
|
+
const key = ids.join("|");
|
|
246
|
+
const existing = q("SELECT id, kind, title, auto, repo_key AS repoKey, created_at AS createdAt FROM threads WHERE participant_key=?").get(key);
|
|
247
|
+
if (existing) return materializeThread(existing);
|
|
248
|
+
|
|
249
|
+
const id = randomUUID();
|
|
250
|
+
const kind = ids.length <= 2 ? "direct" : "group";
|
|
251
|
+
q("INSERT INTO threads(id,kind,title,participant_key,created_at,auto) VALUES(?,?,?,?,?,0)")
|
|
252
|
+
.run(id, kind, title ?? null, key, Date.now());
|
|
253
|
+
const insert = q("INSERT OR IGNORE INTO thread_participants(thread_id,agent_id) VALUES(?,?)");
|
|
254
|
+
for (const agentId of ids) insert.run(id, agentId);
|
|
255
|
+
return materializeThread(threadRow(id));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const repoKeyOf = (agent) => agent?.repo || undefined;
|
|
259
|
+
|
|
260
|
+
function repoTitle(repoKey) {
|
|
261
|
+
const tail = String(repoKey).split(/[\\/]/).filter(Boolean).pop() ?? repoKey;
|
|
262
|
+
return `${tail} (repo)`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Everyone who works in a repo shares one auto-created room. */
|
|
266
|
+
function ensureRepoThread(agent) {
|
|
267
|
+
const repoKey = repoKeyOf(agent);
|
|
268
|
+
if (!repoKey) return undefined;
|
|
269
|
+
let row = q("SELECT id, kind, title, auto, repo_key AS repoKey, created_at AS createdAt FROM threads WHERE repo_key=? AND auto=1").get(repoKey);
|
|
270
|
+
if (!row) {
|
|
271
|
+
const id = randomUUID();
|
|
272
|
+
q("INSERT INTO threads(id,kind,title,participant_key,created_at,auto,repo_key) VALUES(?,?,?,?,?,1,?)")
|
|
273
|
+
.run(id, "group", repoTitle(repoKey), `repo:${repoKey}`, Date.now(), repoKey);
|
|
274
|
+
row = threadRow(id);
|
|
275
|
+
}
|
|
276
|
+
q("INSERT OR IGNORE INTO thread_participants(thread_id,agent_id) VALUES(?,?)").run(row.id, agent.sessionId);
|
|
277
|
+
return row.id;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Housekeeping. Every step is a single set-based statement; the previous
|
|
283
|
+
* version ran a query per thread inside two loops.
|
|
284
|
+
*/
|
|
285
|
+
function purge() {
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
const group = now - GROUP_TTL_MS;
|
|
288
|
+
const direct = now - DIRECT_GRACE_MS;
|
|
289
|
+
|
|
290
|
+
q("DELETE FROM messages WHERE created_at < ? AND thread_id IN (SELECT id FROM threads WHERE auto=1 OR kind='group')").run(group);
|
|
291
|
+
|
|
292
|
+
// Empty auto rooms whose members have all gone.
|
|
293
|
+
q(`DELETE FROM threads WHERE auto=1 AND created_at < ?1
|
|
294
|
+
AND NOT EXISTS (SELECT 1 FROM messages WHERE thread_id=threads.id)
|
|
295
|
+
AND NOT EXISTS (SELECT 1 FROM thread_participants p JOIN agent_seen s ON s.agent_id=p.agent_id
|
|
296
|
+
WHERE p.thread_id=threads.id AND s.last_seen > ?1)`).run(direct);
|
|
297
|
+
|
|
298
|
+
// Idle explicit threads. "Idle" is the newest of: last message, creation,
|
|
299
|
+
// and the last time any participant was seen. Direct threads expire quickly.
|
|
300
|
+
q(`DELETE FROM threads WHERE auto=0 AND MAX(
|
|
301
|
+
created_at,
|
|
302
|
+
COALESCE((SELECT MAX(created_at) FROM messages WHERE thread_id=threads.id), 0),
|
|
303
|
+
COALESCE((SELECT MAX(s.last_seen) FROM thread_participants p JOIN agent_seen s ON s.agent_id=p.agent_id
|
|
304
|
+
WHERE p.thread_id=threads.id), 0)
|
|
305
|
+
) < (CASE kind WHEN 'direct' THEN ? ELSE ? END)`).run(direct, group);
|
|
306
|
+
|
|
307
|
+
q("DELETE FROM agent_seen WHERE last_seen < ?").run(now - GROUP_TTL_MS * 2);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const messages = (threadId, limit = 80) =>
|
|
311
|
+
q(`SELECT id, thread_id AS threadId, sender_type AS senderType, sender_id AS senderId,
|
|
312
|
+
sender_alias AS senderAlias, text, priority, created_at AS createdAt
|
|
313
|
+
FROM messages WHERE thread_id=? ORDER BY created_at DESC LIMIT ?`)
|
|
314
|
+
.all(threadId, Math.max(1, Math.min(Number(limit) || 80, 200)))
|
|
315
|
+
.reverse();
|
|
316
|
+
|
|
317
|
+
/* -------------------------------- transport ------------------------------- */
|
|
318
|
+
|
|
319
|
+
function push(entry, value) {
|
|
320
|
+
if (entry?.ws?.readyState === WebSocket.OPEN) entry.ws.send(JSON.stringify(value));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function pushCoordination(key) {
|
|
324
|
+
const target = liveByKey(key);
|
|
325
|
+
if (target) push(target, { t: "coordination", ...coordination(target.agent) });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function secretMatches(candidate) {
|
|
329
|
+
const a = Buffer.from(String(candidate ?? ""));
|
|
330
|
+
const b = Buffer.from(TOKEN);
|
|
331
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/* -------------------------------- requests -------------------------------- */
|
|
335
|
+
|
|
336
|
+
function handleRequest(selfId, request) {
|
|
337
|
+
const self = liveEntry(selfId);
|
|
338
|
+
if (!self) throw new Error("Agent is no longer registered");
|
|
339
|
+
const actor = request.actor === "user" ? "user" : "agent";
|
|
340
|
+
|
|
341
|
+
switch (request.action) {
|
|
342
|
+
case "agents":
|
|
343
|
+
return liveEntries().filter((e) => e.agent.sessionId !== selfId).map(publicAgent);
|
|
344
|
+
|
|
345
|
+
case "inspect": {
|
|
346
|
+
const target = resolveAgent(request.agent);
|
|
347
|
+
if (!target) throw new Error(`No unique running agent matched '${request.agent ?? ""}'`);
|
|
348
|
+
return { agent: publicAgent(target), activity: readActivity(target, Number(request.limit) || 15) };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case "threads":
|
|
352
|
+
return listThreads(actor, selfId);
|
|
353
|
+
|
|
354
|
+
case "messages": {
|
|
355
|
+
const thread = threadRow(request.thread);
|
|
356
|
+
if (!thread) throw new Error("Unknown thread");
|
|
357
|
+
if (actor !== "user" && !participants(thread.id).includes(selfId)) throw new Error("Not a thread participant");
|
|
358
|
+
return { thread: materializeThread(thread), messages: messages(thread.id, request.limit) };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
case "subscribe": {
|
|
362
|
+
if (actor !== "user") throw new Error("Admin subscription required");
|
|
363
|
+
if (request.thread && !threadRow(request.thread)) throw new Error("Unknown thread");
|
|
364
|
+
if (request.previous) self.adminThreads.delete(String(request.previous));
|
|
365
|
+
if (request.thread) self.adminThreads.add(String(request.thread));
|
|
366
|
+
return { subscribed: request.thread ?? null };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case "coordinators": {
|
|
370
|
+
const rows = q("SELECT agent_key AS agent, coordinator_key AS coordinator, set_by AS setBy, set_at AS setAt FROM coordinators ORDER BY coordinator_key, agent_key").all();
|
|
371
|
+
const online = new Set(liveEntries().map((e) => agentKey(e.agent)));
|
|
372
|
+
return {
|
|
373
|
+
self: coordination(self.agent),
|
|
374
|
+
links: rows.map((row) => ({ ...row, agentOnline: online.has(row.agent), coordinatorOnline: online.has(row.coordinator) })),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
case "set_coordinator": {
|
|
379
|
+
const requested = Array.isArray(request.recipients) ? request.recipients.filter(Boolean) : [];
|
|
380
|
+
const targets = requested.length ? requested : (actor === "agent" ? [agentKey(self.agent)] : []);
|
|
381
|
+
if (!targets.length) throw new Error("set_coordinator requires 'recipients' when acting as the admin user");
|
|
382
|
+
|
|
383
|
+
const raw = String(request.agent ?? "").trim().toLowerCase();
|
|
384
|
+
const clear = !raw || ["none", "clear", "off", "null"].includes(raw);
|
|
385
|
+
const coordinatorKey = clear ? undefined : resolveKey(request.agent);
|
|
386
|
+
if (!clear && !coordinatorKey) throw new Error("set_coordinator requires 'agent', the coordinator alias or session id");
|
|
387
|
+
|
|
388
|
+
const applied = [];
|
|
389
|
+
const touched = new Set();
|
|
390
|
+
for (const needle of targets) {
|
|
391
|
+
const key = resolveKey(needle);
|
|
392
|
+
if (!key) continue;
|
|
393
|
+
if (clear) {
|
|
394
|
+
const previous = coordinatorOf(key)?.coordinatorKey;
|
|
395
|
+
q("DELETE FROM coordinators WHERE agent_key=?").run(key);
|
|
396
|
+
applied.push({ agent: key, coordinator: null });
|
|
397
|
+
touched.add(key);
|
|
398
|
+
if (previous) touched.add(previous);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (key === coordinatorKey) throw new Error(`'${key}' cannot be its own coordinator`);
|
|
402
|
+
if (wouldCycle(key, coordinatorKey)) {
|
|
403
|
+
throw new Error(`Setting '${coordinatorKey}' as coordinator of '${key}' would create a reporting cycle`);
|
|
404
|
+
}
|
|
405
|
+
q(`INSERT INTO coordinators(agent_key,coordinator_key,set_by,set_at) VALUES(?,?,?,?)
|
|
406
|
+
ON CONFLICT(agent_key) DO UPDATE SET coordinator_key=excluded.coordinator_key, set_by=excluded.set_by, set_at=excluded.set_at`)
|
|
407
|
+
.run(key, coordinatorKey, actor === "user" ? "admin" : agentKey(self.agent), Date.now());
|
|
408
|
+
applied.push({ agent: key, coordinator: coordinatorKey });
|
|
409
|
+
touched.add(key);
|
|
410
|
+
touched.add(coordinatorKey);
|
|
411
|
+
}
|
|
412
|
+
for (const key of touched) pushCoordination(key);
|
|
413
|
+
return { applied, self: coordination(self.agent) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
case "report": {
|
|
417
|
+
if (actor !== "agent") throw new Error("report is only available to agents");
|
|
418
|
+
const key = agentKey(self.agent);
|
|
419
|
+
const link = coordinatorOf(key);
|
|
420
|
+
if (!link) throw new Error("No coordinator is set for you. Use action 'set_coordinator' with the coordinator's alias first.");
|
|
421
|
+
const target = liveByKey(link.coordinatorKey);
|
|
422
|
+
if (!target) throw new Error(`Coordinator '${link.coordinatorKey}' is not currently running, so the report was not delivered.`);
|
|
423
|
+
const body = String(request.message ?? "").trim();
|
|
424
|
+
if (!body) throw new Error("report requires a non-empty message");
|
|
425
|
+
return handleRequest(selfId, {
|
|
426
|
+
...request,
|
|
427
|
+
action: "send",
|
|
428
|
+
actor: "agent",
|
|
429
|
+
thread: undefined,
|
|
430
|
+
recipients: [target.agent.sessionId],
|
|
431
|
+
title: `${key} → ${link.coordinatorKey}`,
|
|
432
|
+
message: `[report from ${key}]\n${body}`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
case "send": {
|
|
437
|
+
const text = String(request.message ?? "").trim().slice(0, MAX_MESSAGE);
|
|
438
|
+
if (!text) throw new Error("send requires a non-empty message");
|
|
439
|
+
|
|
440
|
+
let thread;
|
|
441
|
+
const notRunning = [];
|
|
442
|
+
if (request.thread) {
|
|
443
|
+
const row = threadRow(request.thread);
|
|
444
|
+
if (!row) throw new Error("Unknown thread");
|
|
445
|
+
thread = materializeThread(row);
|
|
446
|
+
if (actor !== "user" && !thread.participantIds.includes(selfId)) throw new Error("Not a thread participant");
|
|
447
|
+
} else {
|
|
448
|
+
const resolved = [];
|
|
449
|
+
for (const needle of Array.isArray(request.recipients) ? request.recipients : []) {
|
|
450
|
+
const match = resolveAgent(needle);
|
|
451
|
+
if (match) resolved.push(match.agent.sessionId);
|
|
452
|
+
else notRunning.push(String(needle));
|
|
453
|
+
}
|
|
454
|
+
if (resolved.length === 0) {
|
|
455
|
+
throw new Error(`No requested recipients are currently running${notRunning.length ? `: ${notRunning.join(", ")}` : ""}`);
|
|
456
|
+
}
|
|
457
|
+
thread = getOrCreateThread(actor === "agent" ? [selfId, ...resolved] : resolved, request.title);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const message = {
|
|
461
|
+
id: randomUUID(),
|
|
462
|
+
threadId: thread.id,
|
|
463
|
+
senderType: actor,
|
|
464
|
+
senderId: actor === "user" ? "admin" : selfId,
|
|
465
|
+
senderAlias: actor === "user" ? "you" : (self.agent.alias || selfId.slice(0, 8)),
|
|
466
|
+
text,
|
|
467
|
+
priority: request.priority === "urgent" ? "urgent" : "normal",
|
|
468
|
+
createdAt: Date.now(),
|
|
469
|
+
};
|
|
470
|
+
q("INSERT INTO messages(id,thread_id,sender_type,sender_id,sender_alias,text,priority,created_at) VALUES(?,?,?,?,?,?,?,?)")
|
|
471
|
+
.run(message.id, message.threadId, message.senderType, message.senderId, message.senderAlias, message.text, message.priority, message.createdAt);
|
|
472
|
+
|
|
473
|
+
// Re-materialize once; the previous version rebuilt this three times.
|
|
474
|
+
const fresh = materializeThread(threadRow(thread.id));
|
|
475
|
+
const delivered = [];
|
|
476
|
+
const pushed = new Set();
|
|
477
|
+
for (const participantId of fresh.participantIds) {
|
|
478
|
+
if (actor === "agent" && participantId === selfId) continue;
|
|
479
|
+
const recipient = liveEntry(participantId);
|
|
480
|
+
if (!recipient) {
|
|
481
|
+
// Auto rooms list everyone who ever worked in the repo; only report absent explicit recipients.
|
|
482
|
+
if (!fresh.auto) notRunning.push(active.get(participantId)?.agent.alias || offlineAgent(participantId).agent.alias);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
push(recipient, { t: "message", thread: fresh, message });
|
|
486
|
+
pushed.add(recipient.agent.sessionId);
|
|
487
|
+
delivered.push(recipient.agent.alias || participantId.slice(0, 8));
|
|
488
|
+
}
|
|
489
|
+
for (const viewer of active.values()) {
|
|
490
|
+
if (viewer === self || pushed.has(viewer.agent.sessionId) || !viewer.adminThreads.has(fresh.id)) continue;
|
|
491
|
+
push(viewer, { t: "message", adminView: true, thread: fresh, message });
|
|
492
|
+
}
|
|
493
|
+
return { thread: fresh, message, delivered, notRunning: [...new Set(notRunning)] };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
default:
|
|
497
|
+
throw new Error(`Unknown action '${request.action}'`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* ---------------------------------- http ---------------------------------- */
|
|
502
|
+
|
|
503
|
+
function json(res, status, value) {
|
|
504
|
+
const body = JSON.stringify(value);
|
|
505
|
+
res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
|
506
|
+
res.end(body);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const isAuthorized = (req) => {
|
|
510
|
+
const header = req.headers.authorization ?? "";
|
|
511
|
+
return secretMatches(header.startsWith("Bearer ") ? header.slice(7) : header);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
function handleHttp(req, res) {
|
|
515
|
+
if (req.url === "/health") return json(res, 200, { ok: true, activeAgents: active.size });
|
|
516
|
+
if (!isAuthorized(req)) return json(res, 401, { error: "unauthorized" });
|
|
517
|
+
if (req.url === "/stats") {
|
|
518
|
+
return json(res, 200, {
|
|
519
|
+
activeAgents: active.size,
|
|
520
|
+
threads: q("SELECT COUNT(*) AS n FROM threads").get().n,
|
|
521
|
+
messages: q("SELECT COUNT(*) AS n FROM messages").get().n,
|
|
522
|
+
statements: statements.size,
|
|
523
|
+
rss: process.memoryUsage().rss,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
if (req.url === "/clear" && req.method === "POST") {
|
|
527
|
+
// Wipe conversational state. Coordinator links and presence are kept so a
|
|
528
|
+
// clear does not silently dismantle a reporting structure.
|
|
529
|
+
const before = q("SELECT COUNT(*) AS n FROM messages").get().n;
|
|
530
|
+
db.exec("DELETE FROM messages; DELETE FROM thread_participants; DELETE FROM threads;");
|
|
531
|
+
db.exec("VACUUM");
|
|
532
|
+
for (const entry of active.values()) {
|
|
533
|
+
entry.adminThreads.clear();
|
|
534
|
+
// Re-create the repo room so live agents stay reachable immediately.
|
|
535
|
+
ensureRepoThread(entry.agent);
|
|
536
|
+
}
|
|
537
|
+
return json(res, 200, { cleared: before });
|
|
538
|
+
}
|
|
539
|
+
if (req.url === "/clear-all" && req.method === "POST") {
|
|
540
|
+
const before = q("SELECT COUNT(*) AS n FROM messages").get().n;
|
|
541
|
+
db.exec("DELETE FROM messages; DELETE FROM thread_participants; DELETE FROM threads; DELETE FROM coordinators; DELETE FROM agent_seen;");
|
|
542
|
+
db.exec("VACUUM");
|
|
543
|
+
for (const entry of active.values()) {
|
|
544
|
+
entry.adminThreads.clear();
|
|
545
|
+
markSeen(entry.agent);
|
|
546
|
+
ensureRepoThread(entry.agent);
|
|
547
|
+
push(entry, { t: "coordination", ...coordination(entry.agent) });
|
|
548
|
+
}
|
|
549
|
+
return json(res, 200, { cleared: before, coordinators: true });
|
|
550
|
+
}
|
|
551
|
+
return json(res, 404, { error: "not found" });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function onConnection(ws) {
|
|
555
|
+
let sessionId;
|
|
556
|
+
|
|
557
|
+
ws.on("message", (raw) => {
|
|
558
|
+
let value;
|
|
559
|
+
try {
|
|
560
|
+
value = JSON.parse(raw.toString());
|
|
561
|
+
} catch {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (value.t === "register") {
|
|
566
|
+
if (!secretMatches(value.token)) {
|
|
567
|
+
push({ ws }, { t: "error", error: "unauthorized" });
|
|
568
|
+
ws.close(4401, "unauthorized");
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const agent = value.agent ?? {};
|
|
572
|
+
sessionId = agent.sessionId;
|
|
573
|
+
if (!sessionId) {
|
|
574
|
+
ws.close(4400, "sessionId required");
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
active.set(sessionId, { ws, agent, activity: Array.from({ length: ACTIVITY_RING }), cursor: 0, adminThreads: new Set() });
|
|
578
|
+
markSeen(agent);
|
|
579
|
+
const repoThread = ensureRepoThread(agent);
|
|
580
|
+
push(active.get(sessionId), { t: "registered", ...coordination(agent), repoThread });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const entry = liveEntry(sessionId);
|
|
585
|
+
if (!entry) return;
|
|
586
|
+
|
|
587
|
+
if (value.t === "presence") {
|
|
588
|
+
entry.agent = { ...entry.agent, ...value.agent };
|
|
589
|
+
markSeen(entry.agent);
|
|
590
|
+
if (value.agent?.repo) ensureRepoThread(entry.agent);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (value.t === "activity") {
|
|
595
|
+
recordActivity(entry, { ...value.event, at: value.event?.at ?? Date.now() });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (value.t === "req") {
|
|
600
|
+
try {
|
|
601
|
+
push(entry, { t: "res", id: value.id, ok: true, data: handleRequest(sessionId, value) });
|
|
602
|
+
} catch (error) {
|
|
603
|
+
push(entry, { t: "res", id: value.id, ok: false, error: error?.message ?? String(error) });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
ws.on("close", () => {
|
|
609
|
+
if (sessionId && active.get(sessionId)?.ws === ws) active.delete(sessionId);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// One listener per bound address: loopback for local health checks, plus the
|
|
614
|
+
// tailnet address agents actually connect through. They share all state.
|
|
615
|
+
const listeners = HOSTS.map((host) => {
|
|
616
|
+
const httpServer = http.createServer(handleHttp);
|
|
617
|
+
const wss = new WebSocketServer({ server: httpServer, path: "/ws", maxPayload: MAX_MESSAGE * 4 });
|
|
618
|
+
wss.on("connection", onConnection);
|
|
619
|
+
httpServer.on("error", (error) => console.error(`agent-board listen error on ${host}:${PORT}: ${error.message}`));
|
|
620
|
+
httpServer.listen(PORT, host, () => console.log(`agent-board listening on http://${host}:${PORT}, db=${DB_PATH}`));
|
|
621
|
+
return { httpServer, wss };
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const timer = setInterval(purge, PURGE_INTERVAL_MS);
|
|
625
|
+
timer.unref?.();
|
|
626
|
+
|
|
627
|
+
function shutdown() {
|
|
628
|
+
clearInterval(timer);
|
|
629
|
+
for (const entry of active.values()) entry.ws.close(1001, "server shutting down");
|
|
630
|
+
for (const { httpServer, wss } of listeners) {
|
|
631
|
+
wss.close();
|
|
632
|
+
httpServer.close();
|
|
633
|
+
}
|
|
634
|
+
try {
|
|
635
|
+
db.close();
|
|
636
|
+
} catch { /* already closed */ }
|
|
637
|
+
process.exit(0);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
process.on("SIGINT", shutdown);
|
|
641
|
+
process.on("SIGTERM", shutdown);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-plus-board-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Agent board server for pi-plus",
|
|
7
|
+
"main": "board-server.mjs",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node board-server.mjs"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"ws": "^8.18.3"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.5.0"
|
|
16
|
+
}
|
|
17
|
+
}
|