@manuelfedele/postino 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +21 -0
- package/.mcp.json +7 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/commands/postino.md +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +96 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +38 -0
- package/dist/tools/messaging.d.ts +2 -0
- package/dist/tools/messaging.js +276 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.js +20 -0
- package/dist/valkey.d.ts +19 -0
- package/dist/valkey.js +109 -0
- package/dist/web/api.d.ts +2 -0
- package/dist/web/api.js +145 -0
- package/dist/web/public/favicon.svg +9 -0
- package/dist/web/public/index.html +301 -0
- package/dist/web/public/logo-dark.svg +31 -0
- package/dist/web/public/logo-horizontal.svg +32 -0
- package/dist/web/public/logo.svg +141 -0
- package/dist/web/server.d.ts +1 -0
- package/dist/web/server.js +71 -0
- package/hooks/check-messages.sh +30 -0
- package/hooks/hooks.json +17 -0
- package/package.json +46 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { valkey, keys, publishEvent, getOnlineAgents, renameAgent } from "../valkey.js";
|
|
3
|
+
import { loadConfig } from "../types.js";
|
|
4
|
+
const config = loadConfig();
|
|
5
|
+
// Mutable agent identity so msg_rename can update it
|
|
6
|
+
const identity = { name: "" };
|
|
7
|
+
export function registerMessagingTools(server, initialName) {
|
|
8
|
+
identity.name = initialName;
|
|
9
|
+
server.registerTool("msg_whoami", {
|
|
10
|
+
title: "Status Overview",
|
|
11
|
+
description: [
|
|
12
|
+
"Get a full status overview: your identity, unread messages, unseen broadcasts, and all online agents.",
|
|
13
|
+
"Call this at the start of a session to orient yourself.",
|
|
14
|
+
"Returns everything you need in one call, no need to call msg_check or msg_list_agents separately.",
|
|
15
|
+
].join(" "),
|
|
16
|
+
inputSchema: {},
|
|
17
|
+
}, async () => {
|
|
18
|
+
// Unread messages
|
|
19
|
+
const unread = await valkey.llen(keys.inbox(identity.name));
|
|
20
|
+
// Unseen broadcasts
|
|
21
|
+
const allBc = await valkey.lrange(keys.broadcasts(), 0, -1);
|
|
22
|
+
const cursorStr = await valkey.get(keys.broadcastCursor(identity.name));
|
|
23
|
+
const cursor = cursorStr ? parseInt(cursorStr, 10) : 0;
|
|
24
|
+
const unseenBc = allBc.length - cursor;
|
|
25
|
+
// Online agents
|
|
26
|
+
const allAgents = await valkey.smembers(keys.agents());
|
|
27
|
+
const onlineSet = new Set(await getOnlineAgents());
|
|
28
|
+
const agentList = [];
|
|
29
|
+
for (const name of allAgents.sort()) {
|
|
30
|
+
const msgCount = await valkey.llen(keys.inbox(name));
|
|
31
|
+
agentList.push({
|
|
32
|
+
name,
|
|
33
|
+
online: onlineSet.has(name),
|
|
34
|
+
messages: msgCount,
|
|
35
|
+
isMe: name === identity.name,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const status = {
|
|
39
|
+
me: identity.name,
|
|
40
|
+
unreadMessages: unread,
|
|
41
|
+
unseenBroadcasts: Math.max(0, unseenBc),
|
|
42
|
+
totalBroadcasts: allBc.length,
|
|
43
|
+
agents: agentList,
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
content: [{
|
|
47
|
+
type: "text",
|
|
48
|
+
text: JSON.stringify(status, null, 2),
|
|
49
|
+
}],
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
server.registerTool("msg_rename", {
|
|
53
|
+
title: "Rename Agent",
|
|
54
|
+
description: [
|
|
55
|
+
"Rename this agent to a meaningful name (e.g. 'devops-agent', 'frontend-dev', 'reviewer').",
|
|
56
|
+
"This moves your inbox and updates your identity across the system.",
|
|
57
|
+
"Other agents will see your new name immediately.",
|
|
58
|
+
].join(" "),
|
|
59
|
+
inputSchema: {
|
|
60
|
+
name: z.string().describe("New agent name (e.g. 'devops-agent', 'code-reviewer')"),
|
|
61
|
+
},
|
|
62
|
+
}, async ({ name }) => {
|
|
63
|
+
const oldName = identity.name;
|
|
64
|
+
if (name === oldName) {
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: "text", text: `Already named "${name}"` }],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// Check if name is taken by an online agent
|
|
70
|
+
const online = await getOnlineAgents();
|
|
71
|
+
if (online.includes(name)) {
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: "text", text: `Name "${name}" is taken by an online agent` }],
|
|
74
|
+
isError: true,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
await renameAgent(oldName, name);
|
|
78
|
+
identity.name = name;
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: `Renamed from "${oldName}" to "${name}"` }],
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
server.registerTool("msg_list_agents", {
|
|
84
|
+
title: "List Agents",
|
|
85
|
+
description: [
|
|
86
|
+
"List all known agents and their status.",
|
|
87
|
+
"Shows which agents are currently online (have an active MCP server running)",
|
|
88
|
+
"and how many messages are in each agent's inbox.",
|
|
89
|
+
"Use this to discover who you can send messages to.",
|
|
90
|
+
].join(" "),
|
|
91
|
+
inputSchema: {},
|
|
92
|
+
}, async () => {
|
|
93
|
+
const allAgents = await valkey.smembers(keys.agents());
|
|
94
|
+
const onlineSet = new Set(await getOnlineAgents());
|
|
95
|
+
const result = [];
|
|
96
|
+
for (const name of allAgents.sort()) {
|
|
97
|
+
const msgCount = await valkey.llen(keys.inbox(name));
|
|
98
|
+
result.push({
|
|
99
|
+
name,
|
|
100
|
+
online: onlineSet.has(name),
|
|
101
|
+
messages: msgCount,
|
|
102
|
+
isMe: name === identity.name,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
content: [{
|
|
107
|
+
type: "text",
|
|
108
|
+
text: result.length > 0
|
|
109
|
+
? JSON.stringify(result, null, 2)
|
|
110
|
+
: "No agents registered yet. Send a message to create an inbox.",
|
|
111
|
+
}],
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
server.registerTool("msg_check", {
|
|
115
|
+
title: "Check for New Activity",
|
|
116
|
+
description: [
|
|
117
|
+
"Quick check for new messages and broadcasts without consuming them.",
|
|
118
|
+
"Returns unread message count and unseen broadcast count.",
|
|
119
|
+
"Use this to decide whether to call msg_read or msg_broadcasts.",
|
|
120
|
+
].join(" "),
|
|
121
|
+
inputSchema: {},
|
|
122
|
+
}, async () => {
|
|
123
|
+
const msgCount = await valkey.llen(keys.inbox(identity.name));
|
|
124
|
+
const allBc = await valkey.lrange(keys.broadcasts(), 0, -1);
|
|
125
|
+
const cursorStr = await valkey.get(keys.broadcastCursor(identity.name));
|
|
126
|
+
const cursor = cursorStr ? parseInt(cursorStr, 10) : 0;
|
|
127
|
+
const unseenBc = Math.max(0, allBc.length - cursor);
|
|
128
|
+
const parts = [];
|
|
129
|
+
if (msgCount > 0)
|
|
130
|
+
parts.push(`${msgCount} unread message${msgCount !== 1 ? "s" : ""}`);
|
|
131
|
+
if (unseenBc > 0)
|
|
132
|
+
parts.push(`${unseenBc} unseen broadcast${unseenBc !== 1 ? "s" : ""}`);
|
|
133
|
+
return {
|
|
134
|
+
content: [{
|
|
135
|
+
type: "text",
|
|
136
|
+
text: parts.length > 0
|
|
137
|
+
? parts.join(", ")
|
|
138
|
+
: "No new messages or broadcasts",
|
|
139
|
+
}],
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
server.registerTool("msg_send", {
|
|
143
|
+
title: "Send Message",
|
|
144
|
+
description: [
|
|
145
|
+
"Send a 1-to-1 message to another agent's inbox.",
|
|
146
|
+
"Messages are queued until consumed by msg_read (work queue pattern).",
|
|
147
|
+
"Use msg_whoami or msg_list_agents to discover available agents.",
|
|
148
|
+
"For announcements to ALL agents, use msg_broadcast instead.",
|
|
149
|
+
].join(" "),
|
|
150
|
+
inputSchema: {
|
|
151
|
+
to: z.string().describe("Target agent name. Use msg_list_agents to see available agents."),
|
|
152
|
+
body: z.string().describe("Message body"),
|
|
153
|
+
},
|
|
154
|
+
}, async ({ to, body }) => {
|
|
155
|
+
const msg = {
|
|
156
|
+
id: crypto.randomUUID(),
|
|
157
|
+
from: identity.name,
|
|
158
|
+
to,
|
|
159
|
+
body,
|
|
160
|
+
timestamp: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
await valkey.rpush(keys.inbox(to), JSON.stringify(msg));
|
|
163
|
+
await valkey.expire(keys.inbox(to), config.msgTtl);
|
|
164
|
+
await valkey.sadd(keys.agents(), to);
|
|
165
|
+
await valkey.sadd(keys.agents(), identity.name);
|
|
166
|
+
await valkey.publish(keys.notifyChannel(to), JSON.stringify(msg));
|
|
167
|
+
await publishEvent("msg_send", { from: identity.name, to, messageId: msg.id });
|
|
168
|
+
return {
|
|
169
|
+
content: [{ type: "text", text: `Message sent to "${to}" (id: ${msg.id})` }],
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
server.registerTool("msg_read", {
|
|
173
|
+
title: "Read Messages",
|
|
174
|
+
description: [
|
|
175
|
+
"Read and consume messages from your inbox (messages are removed after reading).",
|
|
176
|
+
"Use msg_check first to see if there are messages without consuming them.",
|
|
177
|
+
"Call this when msg_whoami or msg_check reports unread messages.",
|
|
178
|
+
].join(" "),
|
|
179
|
+
inputSchema: {
|
|
180
|
+
inbox: z.string().optional().describe("Inbox to read. Defaults to this agent's own inbox."),
|
|
181
|
+
limit: z.number().optional().default(20).describe("Maximum messages to read"),
|
|
182
|
+
},
|
|
183
|
+
}, async ({ inbox, limit }) => {
|
|
184
|
+
const target = inbox ?? identity.name;
|
|
185
|
+
const maxMessages = limit ?? 20;
|
|
186
|
+
const raw = await valkey.lrange(keys.inbox(target), 0, maxMessages - 1);
|
|
187
|
+
if (raw.length === 0) {
|
|
188
|
+
return {
|
|
189
|
+
content: [{ type: "text", text: `No messages in inbox "${target}"` }],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const messages = raw.map((r) => JSON.parse(r));
|
|
193
|
+
// Always consume: remove the messages we just read
|
|
194
|
+
await valkey.ltrim(keys.inbox(target), raw.length, -1);
|
|
195
|
+
await publishEvent("msg_read", { inbox: target, count: raw.length });
|
|
196
|
+
await valkey.sadd(keys.agents(), target);
|
|
197
|
+
return {
|
|
198
|
+
content: [{
|
|
199
|
+
type: "text",
|
|
200
|
+
text: JSON.stringify(messages, null, 2),
|
|
201
|
+
}],
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
server.registerTool("msg_broadcast", {
|
|
205
|
+
title: "Broadcast Message",
|
|
206
|
+
description: [
|
|
207
|
+
"Broadcast a message to ALL agents.",
|
|
208
|
+
"Unlike msg_send, broadcasts are not consumed on read. Every agent sees them.",
|
|
209
|
+
"Broadcasts expire after the configured TTL (default 24h).",
|
|
210
|
+
"Use this for announcements: deploy freezes, CI status, completed migrations.",
|
|
211
|
+
].join(" "),
|
|
212
|
+
inputSchema: {
|
|
213
|
+
body: z.string().describe("Broadcast message body"),
|
|
214
|
+
},
|
|
215
|
+
}, async ({ body }) => {
|
|
216
|
+
const bc = {
|
|
217
|
+
id: crypto.randomUUID(),
|
|
218
|
+
from: identity.name,
|
|
219
|
+
body,
|
|
220
|
+
timestamp: new Date().toISOString(),
|
|
221
|
+
};
|
|
222
|
+
await valkey.rpush(keys.broadcasts(), JSON.stringify(bc));
|
|
223
|
+
await valkey.expire(keys.broadcasts(), config.msgTtl);
|
|
224
|
+
await publishEvent("broadcast", { from: identity.name, messageId: bc.id });
|
|
225
|
+
return {
|
|
226
|
+
content: [{ type: "text", text: `Broadcast sent (id: ${bc.id})` }],
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
server.registerTool("msg_broadcasts", {
|
|
230
|
+
title: "Read Broadcasts",
|
|
231
|
+
description: [
|
|
232
|
+
"Read broadcast messages. Shows new broadcasts since your last check.",
|
|
233
|
+
"Broadcasts are shared across all agents and expire by TTL (not consumed on read).",
|
|
234
|
+
"Use all=true to see all broadcasts, not just unseen ones.",
|
|
235
|
+
].join(" "),
|
|
236
|
+
inputSchema: {
|
|
237
|
+
all: z.boolean().optional().default(false).describe("Show all broadcasts, not just unseen"),
|
|
238
|
+
},
|
|
239
|
+
}, async ({ all: showAll }) => {
|
|
240
|
+
const raw = await valkey.lrange(keys.broadcasts(), 0, -1);
|
|
241
|
+
if (raw.length === 0) {
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: "text", text: "No broadcasts" }],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const broadcasts = raw.map((r) => JSON.parse(r));
|
|
247
|
+
if (showAll) {
|
|
248
|
+
await valkey.set(keys.broadcastCursor(identity.name), String(raw.length), "EX", config.msgTtl);
|
|
249
|
+
return {
|
|
250
|
+
content: [{
|
|
251
|
+
type: "text",
|
|
252
|
+
text: JSON.stringify({ total: broadcasts.length, broadcasts }, null, 2),
|
|
253
|
+
}],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const cursorStr = await valkey.get(keys.broadcastCursor(identity.name));
|
|
257
|
+
const cursor = cursorStr ? parseInt(cursorStr, 10) : 0;
|
|
258
|
+
const unseen = broadcasts.slice(cursor);
|
|
259
|
+
// Update cursor
|
|
260
|
+
await valkey.set(keys.broadcastCursor(identity.name), String(raw.length), "EX", config.msgTtl);
|
|
261
|
+
if (unseen.length === 0) {
|
|
262
|
+
return {
|
|
263
|
+
content: [{
|
|
264
|
+
type: "text",
|
|
265
|
+
text: `No new broadcasts (${broadcasts.length} total, all seen)`,
|
|
266
|
+
}],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
content: [{
|
|
271
|
+
type: "text",
|
|
272
|
+
text: JSON.stringify({ unseen: unseen.length, total: broadcasts.length, broadcasts: unseen }, null, 2),
|
|
273
|
+
}],
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface Message {
|
|
2
|
+
id: string;
|
|
3
|
+
from: string;
|
|
4
|
+
to: string;
|
|
5
|
+
body: string;
|
|
6
|
+
timestamp: string;
|
|
7
|
+
}
|
|
8
|
+
export interface Broadcast {
|
|
9
|
+
id: string;
|
|
10
|
+
from: string;
|
|
11
|
+
body: string;
|
|
12
|
+
timestamp: string;
|
|
13
|
+
}
|
|
14
|
+
export interface Config {
|
|
15
|
+
valkeyUrl: string;
|
|
16
|
+
webPort: number;
|
|
17
|
+
webEnabled: boolean;
|
|
18
|
+
keyPrefix: string;
|
|
19
|
+
agentName: string;
|
|
20
|
+
msgTtl: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function loadConfig(): Config;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function resolveAgentName() {
|
|
2
|
+
if (process.env.POSTINO_AGENT_NAME)
|
|
3
|
+
return process.env.POSTINO_AGENT_NAME;
|
|
4
|
+
const sessionId = process.env.TERM_SESSION_ID || process.env.ITERM_SESSION_ID;
|
|
5
|
+
if (sessionId) {
|
|
6
|
+
const short = sessionId.split(":").pop()?.slice(0, 8) ?? "";
|
|
7
|
+
return `agent-${short}`;
|
|
8
|
+
}
|
|
9
|
+
return `agent-${process.pid}`;
|
|
10
|
+
}
|
|
11
|
+
export function loadConfig() {
|
|
12
|
+
return {
|
|
13
|
+
valkeyUrl: process.env.POSTINO_VALKEY_URL ?? "redis://127.0.0.1:6379",
|
|
14
|
+
webPort: parseInt(process.env.POSTINO_WEB_PORT ?? "3333", 10),
|
|
15
|
+
webEnabled: process.env.POSTINO_WEB_ENABLED !== "false",
|
|
16
|
+
keyPrefix: process.env.POSTINO_KEY_PREFIX ?? "po:",
|
|
17
|
+
agentName: resolveAgentName(),
|
|
18
|
+
msgTtl: parseInt(process.env.POSTINO_MSG_TTL ?? "86400", 10),
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/valkey.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Redis } from "ioredis";
|
|
2
|
+
export declare const valkey: Redis;
|
|
3
|
+
export declare const valkeySub: Redis;
|
|
4
|
+
export declare const keys: {
|
|
5
|
+
inbox: (agent: string) => string;
|
|
6
|
+
agents: () => string;
|
|
7
|
+
agentInfo: (agent: string) => string;
|
|
8
|
+
broadcasts: () => string;
|
|
9
|
+
broadcastCursor: (agent: string) => string;
|
|
10
|
+
notifyChannel: (agent: string) => string;
|
|
11
|
+
eventsChannel: () => string;
|
|
12
|
+
};
|
|
13
|
+
export declare function connect(): Promise<void>;
|
|
14
|
+
export declare function disconnect(): Promise<void>;
|
|
15
|
+
export declare function publishEvent(type: string, data: Record<string, unknown>): Promise<void>;
|
|
16
|
+
export declare function registerAgent(name: string): Promise<void>;
|
|
17
|
+
export declare function deregisterAgent(name: string): Promise<void>;
|
|
18
|
+
export declare function renameAgent(oldName: string, newName: string): Promise<void>;
|
|
19
|
+
export declare function getOnlineAgents(): Promise<string[]>;
|
package/dist/valkey.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Redis } from "ioredis";
|
|
2
|
+
import { loadConfig } from "./types.js";
|
|
3
|
+
const config = loadConfig();
|
|
4
|
+
export const valkey = new Redis(config.valkeyUrl, {
|
|
5
|
+
lazyConnect: true,
|
|
6
|
+
maxRetriesPerRequest: 3,
|
|
7
|
+
});
|
|
8
|
+
export const valkeySub = new Redis(config.valkeyUrl, {
|
|
9
|
+
lazyConnect: true,
|
|
10
|
+
maxRetriesPerRequest: 3,
|
|
11
|
+
});
|
|
12
|
+
const prefix = config.keyPrefix;
|
|
13
|
+
export const keys = {
|
|
14
|
+
inbox: (agent) => `${prefix}inbox:${agent}`,
|
|
15
|
+
agents: () => `${prefix}agents`,
|
|
16
|
+
agentInfo: (agent) => `${prefix}agent:${agent}`,
|
|
17
|
+
broadcasts: () => `${prefix}broadcasts`,
|
|
18
|
+
broadcastCursor: (agent) => `${prefix}bcursor:${agent}`,
|
|
19
|
+
notifyChannel: (agent) => `${prefix}notify:${agent}`,
|
|
20
|
+
eventsChannel: () => `${prefix}events`,
|
|
21
|
+
};
|
|
22
|
+
export async function connect() {
|
|
23
|
+
await valkey.connect();
|
|
24
|
+
await valkeySub.connect();
|
|
25
|
+
}
|
|
26
|
+
export async function disconnect() {
|
|
27
|
+
await valkey.quit();
|
|
28
|
+
await valkeySub.quit();
|
|
29
|
+
}
|
|
30
|
+
export async function publishEvent(type, data) {
|
|
31
|
+
const event = JSON.stringify({ type, ...data, timestamp: new Date().toISOString() });
|
|
32
|
+
await valkey.publish(keys.eventsChannel(), event);
|
|
33
|
+
}
|
|
34
|
+
const HEARTBEAT_TTL = 30;
|
|
35
|
+
const HEARTBEAT_INTERVAL = 15_000;
|
|
36
|
+
let heartbeatTimer = null;
|
|
37
|
+
export async function registerAgent(name) {
|
|
38
|
+
const info = JSON.stringify({
|
|
39
|
+
name,
|
|
40
|
+
pid: process.pid,
|
|
41
|
+
started_at: new Date().toISOString(),
|
|
42
|
+
});
|
|
43
|
+
await valkey.set(keys.agentInfo(name), info, "EX", HEARTBEAT_TTL);
|
|
44
|
+
await valkey.sadd(keys.agents(), name);
|
|
45
|
+
await publishEvent("agent_online", { agent: name });
|
|
46
|
+
heartbeatTimer = setInterval(async () => {
|
|
47
|
+
try {
|
|
48
|
+
await valkey.expire(keys.agentInfo(name), HEARTBEAT_TTL);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Connection lost
|
|
52
|
+
}
|
|
53
|
+
}, HEARTBEAT_INTERVAL);
|
|
54
|
+
}
|
|
55
|
+
export async function deregisterAgent(name) {
|
|
56
|
+
if (heartbeatTimer)
|
|
57
|
+
clearInterval(heartbeatTimer);
|
|
58
|
+
await valkey.del(keys.agentInfo(name));
|
|
59
|
+
await publishEvent("agent_offline", { agent: name });
|
|
60
|
+
}
|
|
61
|
+
export async function renameAgent(oldName, newName) {
|
|
62
|
+
// Stop heartbeat for old name
|
|
63
|
+
if (heartbeatTimer)
|
|
64
|
+
clearInterval(heartbeatTimer);
|
|
65
|
+
// Move inbox messages
|
|
66
|
+
const messages = await valkey.lrange(keys.inbox(oldName), 0, -1);
|
|
67
|
+
if (messages.length > 0) {
|
|
68
|
+
await valkey.rpush(keys.inbox(newName), ...messages);
|
|
69
|
+
}
|
|
70
|
+
await valkey.del(keys.inbox(oldName));
|
|
71
|
+
// Move broadcast cursor
|
|
72
|
+
const cursor = await valkey.get(keys.broadcastCursor(oldName));
|
|
73
|
+
if (cursor) {
|
|
74
|
+
const ttl = await valkey.ttl(keys.broadcastCursor(oldName));
|
|
75
|
+
await valkey.set(keys.broadcastCursor(newName), cursor, "EX", ttl > 0 ? ttl : HEARTBEAT_TTL);
|
|
76
|
+
await valkey.del(keys.broadcastCursor(oldName));
|
|
77
|
+
}
|
|
78
|
+
// Update agents set
|
|
79
|
+
await valkey.srem(keys.agents(), oldName);
|
|
80
|
+
await valkey.sadd(keys.agents(), newName);
|
|
81
|
+
// Deregister old, register new
|
|
82
|
+
await valkey.del(keys.agentInfo(oldName));
|
|
83
|
+
const info = JSON.stringify({
|
|
84
|
+
name: newName,
|
|
85
|
+
pid: process.pid,
|
|
86
|
+
started_at: new Date().toISOString(),
|
|
87
|
+
});
|
|
88
|
+
await valkey.set(keys.agentInfo(newName), info, "EX", HEARTBEAT_TTL);
|
|
89
|
+
// Restart heartbeat for new name
|
|
90
|
+
heartbeatTimer = setInterval(async () => {
|
|
91
|
+
try {
|
|
92
|
+
await valkey.expire(keys.agentInfo(newName), HEARTBEAT_TTL);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Connection lost
|
|
96
|
+
}
|
|
97
|
+
}, HEARTBEAT_INTERVAL);
|
|
98
|
+
await publishEvent("agent_rename", { oldName, newName });
|
|
99
|
+
}
|
|
100
|
+
export async function getOnlineAgents() {
|
|
101
|
+
const allAgents = await valkey.smembers(keys.agents());
|
|
102
|
+
const online = [];
|
|
103
|
+
for (const name of allAgents) {
|
|
104
|
+
const exists = await valkey.exists(keys.agentInfo(name));
|
|
105
|
+
if (exists)
|
|
106
|
+
online.push(name);
|
|
107
|
+
}
|
|
108
|
+
return online.sort();
|
|
109
|
+
}
|
package/dist/web/api.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
3
|
+
import { valkey, valkeySub, keys, publishEvent, getOnlineAgents } from "../valkey.js";
|
|
4
|
+
import { loadConfig } from "../types.js";
|
|
5
|
+
const config = loadConfig();
|
|
6
|
+
export const api = new Hono();
|
|
7
|
+
// --- Agents ---
|
|
8
|
+
api.get("/agents", async (c) => {
|
|
9
|
+
const agents = await valkey.smembers(keys.agents());
|
|
10
|
+
agents.sort();
|
|
11
|
+
const onlineSet = new Set(await getOnlineAgents());
|
|
12
|
+
const result = [];
|
|
13
|
+
for (const agent of agents) {
|
|
14
|
+
const count = await valkey.llen(keys.inbox(agent));
|
|
15
|
+
result.push({ name: agent, messageCount: count, online: onlineSet.has(agent) });
|
|
16
|
+
}
|
|
17
|
+
return c.json(result);
|
|
18
|
+
});
|
|
19
|
+
// --- Messages ---
|
|
20
|
+
api.get("/messages/:inbox", async (c) => {
|
|
21
|
+
const inbox = c.req.param("inbox");
|
|
22
|
+
const raw = await valkey.lrange(keys.inbox(inbox), 0, -1);
|
|
23
|
+
const messages = raw.map((r) => JSON.parse(r));
|
|
24
|
+
return c.json(messages);
|
|
25
|
+
});
|
|
26
|
+
api.post("/messages", async (c) => {
|
|
27
|
+
const body = await c.req.json();
|
|
28
|
+
const msg = {
|
|
29
|
+
id: crypto.randomUUID(),
|
|
30
|
+
from: body.from || "web-ui",
|
|
31
|
+
to: body.to,
|
|
32
|
+
body: body.body,
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
};
|
|
35
|
+
await valkey.rpush(keys.inbox(msg.to), JSON.stringify(msg));
|
|
36
|
+
await valkey.expire(keys.inbox(msg.to), config.msgTtl);
|
|
37
|
+
await valkey.sadd(keys.agents(), msg.to);
|
|
38
|
+
if (msg.from !== "anonymous") {
|
|
39
|
+
await valkey.sadd(keys.agents(), msg.from);
|
|
40
|
+
}
|
|
41
|
+
await valkey.publish(keys.notifyChannel(msg.to), JSON.stringify(msg));
|
|
42
|
+
await publishEvent("msg_send", { from: msg.from, to: msg.to, messageId: msg.id });
|
|
43
|
+
return c.json({ ok: true, id: msg.id });
|
|
44
|
+
});
|
|
45
|
+
api.delete("/messages/:inbox", async (c) => {
|
|
46
|
+
const inbox = c.req.param("inbox");
|
|
47
|
+
await valkey.del(keys.inbox(inbox));
|
|
48
|
+
await publishEvent("msg_read", { inbox, count: "all" });
|
|
49
|
+
return c.json({ ok: true });
|
|
50
|
+
});
|
|
51
|
+
// --- Broadcasts ---
|
|
52
|
+
api.get("/broadcasts", async (c) => {
|
|
53
|
+
const all = await valkey.lrange(keys.broadcasts(), 0, -1);
|
|
54
|
+
const broadcasts = all.map((r) => JSON.parse(r));
|
|
55
|
+
return c.json(broadcasts);
|
|
56
|
+
});
|
|
57
|
+
api.post("/broadcasts", async (c) => {
|
|
58
|
+
const body = await c.req.json();
|
|
59
|
+
const bc = {
|
|
60
|
+
id: crypto.randomUUID(),
|
|
61
|
+
from: body.from || "web-ui",
|
|
62
|
+
body: body.body,
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
await valkey.rpush(keys.broadcasts(), JSON.stringify(bc));
|
|
66
|
+
await valkey.expire(keys.broadcasts(), config.msgTtl);
|
|
67
|
+
await publishEvent("broadcast", { from: bc.from, messageId: bc.id });
|
|
68
|
+
return c.json({ ok: true, id: bc.id });
|
|
69
|
+
});
|
|
70
|
+
api.delete("/broadcasts", async (c) => {
|
|
71
|
+
await valkey.del(keys.broadcasts());
|
|
72
|
+
await publishEvent("broadcasts_clear", {});
|
|
73
|
+
return c.json({ ok: true });
|
|
74
|
+
});
|
|
75
|
+
// --- Stats ---
|
|
76
|
+
api.get("/stats", async (c) => {
|
|
77
|
+
const agents = await valkey.smembers(keys.agents());
|
|
78
|
+
let messageCount = 0;
|
|
79
|
+
for (const a of agents) {
|
|
80
|
+
messageCount += await valkey.llen(keys.inbox(a));
|
|
81
|
+
}
|
|
82
|
+
const broadcastCount = await valkey.llen(keys.broadcasts());
|
|
83
|
+
return c.json({ agentCount: agents.length, messageCount, broadcastCount });
|
|
84
|
+
});
|
|
85
|
+
// --- Agent-specific check (for hooks, zero-token) ---
|
|
86
|
+
api.get("/check/:agent", async (c) => {
|
|
87
|
+
const agent = c.req.param("agent");
|
|
88
|
+
const unread = await valkey.llen(keys.inbox(agent));
|
|
89
|
+
const allBc = await valkey.lrange(keys.broadcasts(), 0, -1);
|
|
90
|
+
const cursorStr = await valkey.get(keys.broadcastCursor(agent));
|
|
91
|
+
const cursor = cursorStr ? parseInt(cursorStr, 10) : 0;
|
|
92
|
+
const unseenBc = Math.max(0, allBc.length - cursor);
|
|
93
|
+
return c.json({ agent, unreadMessages: unread, unseenBroadcasts: unseenBc });
|
|
94
|
+
});
|
|
95
|
+
const sseClients = new Set();
|
|
96
|
+
let subscribedToEvents = false;
|
|
97
|
+
function ensureEventSubscription() {
|
|
98
|
+
if (subscribedToEvents)
|
|
99
|
+
return;
|
|
100
|
+
subscribedToEvents = true;
|
|
101
|
+
valkeySub.subscribe(keys.eventsChannel()).catch(() => {
|
|
102
|
+
subscribedToEvents = false;
|
|
103
|
+
});
|
|
104
|
+
valkeySub.on("message", (_channel, message) => {
|
|
105
|
+
for (const client of sseClients) {
|
|
106
|
+
try {
|
|
107
|
+
client.send(message);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
sseClients.delete(client);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
api.get("/events", (c) => {
|
|
116
|
+
ensureEventSubscription();
|
|
117
|
+
return streamSSE(c, async (stream) => {
|
|
118
|
+
const clientId = crypto.randomUUID();
|
|
119
|
+
let alive = true;
|
|
120
|
+
const client = {
|
|
121
|
+
id: clientId,
|
|
122
|
+
send: (data) => {
|
|
123
|
+
if (alive) {
|
|
124
|
+
stream.writeSSE({ data, event: "update" }).catch(() => {
|
|
125
|
+
alive = false;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
close: () => {
|
|
130
|
+
alive = false;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
sseClients.add(client);
|
|
134
|
+
await stream.writeSSE({ data: JSON.stringify({ type: "connected" }), event: "update" });
|
|
135
|
+
while (alive) {
|
|
136
|
+
await stream.sleep(15000);
|
|
137
|
+
if (alive) {
|
|
138
|
+
await stream.writeSSE({ data: "", event: "ping" }).catch(() => {
|
|
139
|
+
alive = false;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
sseClients.delete(client);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<rect width="32" height="32" rx="7" fill="#e63030"/>
|
|
3
|
+
<g transform="translate(16,16)">
|
|
4
|
+
<rect x="-10" y="-4" width="20" height="8" rx="4" fill="none" stroke="white" stroke-width="1.5"/>
|
|
5
|
+
<line x1="-10" y1="-2" x2="-10" y2="2" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
|
|
6
|
+
<rect x="-6" y="-2.5" width="6" height="4" rx="0.5" fill="white"/>
|
|
7
|
+
<rect x="1" y="-2.5" width="6" height="4" rx="0.5" fill="white" opacity="0.5"/>
|
|
8
|
+
</g>
|
|
9
|
+
</svg>
|