@manuelfedele/postino 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +42 -2
- package/dist/tools/messaging.js +20 -1
- package/dist/valkey.d.ts +2 -0
- package/dist/valkey.js +23 -0
- package/dist/web/api.js +19 -2
- package/dist/web/public/index.html +2 -2
- package/dist/web/server.d.ts +6 -0
- package/dist/web/server.js +37 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { loadConfig } from "./types.js";
|
|
5
|
-
import { connect, disconnect, registerAgent, deregisterAgent } from "./valkey.js";
|
|
5
|
+
import { connect, disconnect, valkey, valkeySub, keys, registerAgent, deregisterAgent } from "./valkey.js";
|
|
6
6
|
import { registerMessagingTools } from "./tools/messaging.js";
|
|
7
|
-
import { startWebServer } from "./web/server.js";
|
|
7
|
+
import { startWebServer, getGuiState, restartOnPort } from "./web/server.js";
|
|
8
8
|
const config = loadConfig();
|
|
9
9
|
const server = new McpServer({
|
|
10
10
|
name: "postino",
|
|
@@ -15,6 +15,35 @@ const server = new McpServer({
|
|
|
15
15
|
},
|
|
16
16
|
});
|
|
17
17
|
registerMessagingTools(server, config.agentName);
|
|
18
|
+
function subscribeGuiTakeover() {
|
|
19
|
+
const channel = keys.guiTakeoverChannel();
|
|
20
|
+
valkeySub.subscribe(channel).catch(() => {
|
|
21
|
+
process.stderr.write("postino: failed to subscribe to GUI takeover channel\n");
|
|
22
|
+
});
|
|
23
|
+
valkeySub.on("message", (ch, message) => {
|
|
24
|
+
if (ch !== channel)
|
|
25
|
+
return;
|
|
26
|
+
let data;
|
|
27
|
+
try {
|
|
28
|
+
data = JSON.parse(message);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const state = getGuiState();
|
|
34
|
+
// Skip if we already have a GUI on an equal or lower port
|
|
35
|
+
if (state.running && state.port !== null && state.port <= data.port)
|
|
36
|
+
return;
|
|
37
|
+
// Random jitter (100-500ms) so not all instances race at once
|
|
38
|
+
const jitter = 100 + Math.random() * 400;
|
|
39
|
+
setTimeout(async () => {
|
|
40
|
+
const ok = await restartOnPort(data.port);
|
|
41
|
+
if (ok) {
|
|
42
|
+
process.stderr.write(`postino: took over GUI on port ${data.port}\n`);
|
|
43
|
+
}
|
|
44
|
+
}, jitter);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
18
47
|
async function main() {
|
|
19
48
|
try {
|
|
20
49
|
await connect();
|
|
@@ -36,9 +65,20 @@ async function main() {
|
|
|
36
65
|
await server.connect(transport);
|
|
37
66
|
if (config.webEnabled) {
|
|
38
67
|
startWebServer(config.webPort);
|
|
68
|
+
subscribeGuiTakeover();
|
|
39
69
|
}
|
|
40
70
|
process.stderr.write(`postino agent: ${config.agentName}\n`);
|
|
41
71
|
const shutdown = async () => {
|
|
72
|
+
// If this instance had a GUI, notify others so they can take over the port
|
|
73
|
+
const state = getGuiState();
|
|
74
|
+
if (state.running && state.port !== null) {
|
|
75
|
+
try {
|
|
76
|
+
await valkey.publish(keys.guiTakeoverChannel(), JSON.stringify({ port: state.port }));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Best-effort, connection may already be closing
|
|
80
|
+
}
|
|
81
|
+
}
|
|
42
82
|
await deregisterAgent(config.agentName);
|
|
43
83
|
await disconnect();
|
|
44
84
|
process.exit(0);
|
package/dist/tools/messaging.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { valkey, keys, publishEvent, getOnlineAgents, renameAgent } from "../valkey.js";
|
|
2
|
+
import { valkey, keys, publishEvent, getOnlineAgents, renameAgent, cleanupStaleAgents } from "../valkey.js";
|
|
3
3
|
import { loadConfig } from "../types.js";
|
|
4
4
|
const config = loadConfig();
|
|
5
5
|
// Mutable agent identity so msg_rename can update it
|
|
@@ -273,4 +273,23 @@ export function registerMessagingTools(server, initialName) {
|
|
|
273
273
|
}],
|
|
274
274
|
};
|
|
275
275
|
});
|
|
276
|
+
server.registerTool("msg_cleanup", {
|
|
277
|
+
title: "Cleanup Stale Agents",
|
|
278
|
+
description: [
|
|
279
|
+
"Remove offline agents with empty inboxes from the agents list.",
|
|
280
|
+
"Agents are auto-cleaned on shutdown, but this tool handles stragglers",
|
|
281
|
+
"(e.g. crashed processes that never ran their shutdown handler).",
|
|
282
|
+
].join(" "),
|
|
283
|
+
inputSchema: {},
|
|
284
|
+
}, async () => {
|
|
285
|
+
const removed = await cleanupStaleAgents();
|
|
286
|
+
return {
|
|
287
|
+
content: [{
|
|
288
|
+
type: "text",
|
|
289
|
+
text: removed.length > 0
|
|
290
|
+
? `Removed ${removed.length} stale agent(s): ${removed.join(", ")}`
|
|
291
|
+
: "No stale agents to clean up",
|
|
292
|
+
}],
|
|
293
|
+
};
|
|
294
|
+
});
|
|
276
295
|
}
|
package/dist/valkey.d.ts
CHANGED
|
@@ -9,11 +9,13 @@ export declare const keys: {
|
|
|
9
9
|
broadcastCursor: (agent: string) => string;
|
|
10
10
|
notifyChannel: (agent: string) => string;
|
|
11
11
|
eventsChannel: () => string;
|
|
12
|
+
guiTakeoverChannel: () => string;
|
|
12
13
|
};
|
|
13
14
|
export declare function connect(): Promise<void>;
|
|
14
15
|
export declare function disconnect(): Promise<void>;
|
|
15
16
|
export declare function publishEvent(type: string, data: Record<string, unknown>): Promise<void>;
|
|
16
17
|
export declare function registerAgent(name: string): Promise<void>;
|
|
17
18
|
export declare function deregisterAgent(name: string): Promise<void>;
|
|
19
|
+
export declare function cleanupStaleAgents(): Promise<string[]>;
|
|
18
20
|
export declare function renameAgent(oldName: string, newName: string): Promise<void>;
|
|
19
21
|
export declare function getOnlineAgents(): Promise<string[]>;
|
package/dist/valkey.js
CHANGED
|
@@ -20,6 +20,7 @@ export const keys = {
|
|
|
20
20
|
broadcastCursor: (agent) => `${prefix}bcursor:${agent}`,
|
|
21
21
|
notifyChannel: (agent) => `${prefix}notify:${agent}`,
|
|
22
22
|
eventsChannel: () => `${prefix}events`,
|
|
23
|
+
guiTakeoverChannel: () => `${prefix}gui:takeover`,
|
|
23
24
|
};
|
|
24
25
|
export async function connect() {
|
|
25
26
|
await valkey.connect();
|
|
@@ -60,8 +61,30 @@ export async function deregisterAgent(name) {
|
|
|
60
61
|
if (heartbeatTimer)
|
|
61
62
|
clearInterval(heartbeatTimer);
|
|
62
63
|
await valkey.del(keys.agentInfo(name));
|
|
64
|
+
// Auto-cleanup: remove from agents set if inbox is empty
|
|
65
|
+
const inboxLen = await valkey.llen(keys.inbox(name));
|
|
66
|
+
if (inboxLen === 0) {
|
|
67
|
+
await valkey.srem(keys.agents(), name);
|
|
68
|
+
await valkey.del(keys.broadcastCursor(name));
|
|
69
|
+
}
|
|
63
70
|
await publishEvent("agent_offline", { agent: name });
|
|
64
71
|
}
|
|
72
|
+
export async function cleanupStaleAgents() {
|
|
73
|
+
const allAgents = await valkey.smembers(keys.agents());
|
|
74
|
+
const removed = [];
|
|
75
|
+
for (const name of allAgents) {
|
|
76
|
+
const online = await valkey.exists(keys.agentInfo(name));
|
|
77
|
+
if (online)
|
|
78
|
+
continue;
|
|
79
|
+
const inboxLen = await valkey.llen(keys.inbox(name));
|
|
80
|
+
if (inboxLen === 0) {
|
|
81
|
+
await valkey.srem(keys.agents(), name);
|
|
82
|
+
await valkey.del(keys.broadcastCursor(name));
|
|
83
|
+
removed.push(name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return removed;
|
|
87
|
+
}
|
|
65
88
|
export async function renameAgent(oldName, newName) {
|
|
66
89
|
// Stop heartbeat for old name
|
|
67
90
|
if (heartbeatTimer)
|
package/dist/web/api.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { streamSSE } from "hono/streaming";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
import { valkey, valkeySub, keys, publishEvent, getOnlineAgents } from "../valkey.js";
|
|
4
7
|
import { loadConfig } from "../types.js";
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
function readVersion() {
|
|
10
|
+
try {
|
|
11
|
+
const pkgPath = join(__dirname, "..", "package.json");
|
|
12
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
13
|
+
return pkg.version || "0.0.0";
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return "0.0.0";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const VERSION = readVersion();
|
|
5
20
|
const config = loadConfig();
|
|
6
21
|
export const api = new Hono();
|
|
7
22
|
// --- Agents ---
|
|
@@ -80,7 +95,7 @@ api.get("/stats", async (c) => {
|
|
|
80
95
|
messageCount += await valkey.llen(keys.inbox(a));
|
|
81
96
|
}
|
|
82
97
|
const broadcastCount = await valkey.llen(keys.broadcasts());
|
|
83
|
-
return c.json({ agentCount: agents.length, messageCount, broadcastCount });
|
|
98
|
+
return c.json({ version: VERSION, agentCount: agents.length, messageCount, broadcastCount });
|
|
84
99
|
});
|
|
85
100
|
// --- Agent-specific check (for hooks, zero-token) ---
|
|
86
101
|
api.get("/check/:agent", async (c) => {
|
|
@@ -101,7 +116,9 @@ function ensureEventSubscription() {
|
|
|
101
116
|
valkeySub.subscribe(keys.eventsChannel()).catch(() => {
|
|
102
117
|
subscribedToEvents = false;
|
|
103
118
|
});
|
|
104
|
-
valkeySub.on("message", (
|
|
119
|
+
valkeySub.on("message", (channel, message) => {
|
|
120
|
+
if (channel !== keys.eventsChannel())
|
|
121
|
+
return;
|
|
105
122
|
for (const client of sseClients) {
|
|
106
123
|
try {
|
|
107
124
|
client.send(message);
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
<div class="topbar">
|
|
142
142
|
<div class="logo">
|
|
143
143
|
<h1>postino</h1>
|
|
144
|
-
<span class="pill pill-muted"
|
|
144
|
+
<span class="pill pill-muted" id="version"></span>
|
|
145
145
|
<span class="pill pill-accent" id="agent-id"></span>
|
|
146
146
|
</div>
|
|
147
147
|
<div class="meta">
|
|
@@ -288,7 +288,7 @@ function connectSSE(){
|
|
|
288
288
|
es.onerror=()=>{d.className='dot dot-off';t.textContent='reconnecting'};
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
async function loadStats(){const s=await api('/stats');if(!s)return;document.getElementById('stat-agents').textContent=s.agentCount+' agents';document.getElementById('stat-msg').textContent=s.messageCount+' msg';document.getElementById('stat-bc').textContent=s.broadcastCount+' bc'}
|
|
291
|
+
async function loadStats(){const s=await api('/stats');if(!s)return;document.getElementById('version').textContent='v'+s.version;document.getElementById('stat-agents').textContent=s.agentCount+' agents';document.getElementById('stat-msg').textContent=s.messageCount+' msg';document.getElementById('stat-bc').textContent=s.broadcastCount+' bc'}
|
|
292
292
|
|
|
293
293
|
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
|
294
294
|
function fmtT(i){try{const d=new Date(i),n=new Date();return d.toDateString()===n.toDateString()?d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}):d.toLocaleDateString([],{month:'short',day:'numeric'})+' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}catch{return i}}
|
package/dist/web/server.d.ts
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
+
export interface GuiState {
|
|
2
|
+
running: boolean;
|
|
3
|
+
port: number | null;
|
|
4
|
+
}
|
|
5
|
+
export declare function getGuiState(): GuiState;
|
|
1
6
|
export declare function startWebServer(port: number, attempt?: number): void;
|
|
7
|
+
export declare function restartOnPort(port: number): Promise<boolean>;
|
package/dist/web/server.js
CHANGED
|
@@ -49,9 +49,16 @@ app.get("/", (c) => {
|
|
|
49
49
|
return c.html(html);
|
|
50
50
|
});
|
|
51
51
|
const MAX_PORT_ATTEMPTS = 10;
|
|
52
|
+
let currentServer = null;
|
|
53
|
+
let currentPort = null;
|
|
54
|
+
export function getGuiState() {
|
|
55
|
+
return { running: currentServer !== null, port: currentPort };
|
|
56
|
+
}
|
|
52
57
|
export function startWebServer(port, attempt = 0) {
|
|
53
58
|
try {
|
|
54
59
|
const server = serve({ fetch: app.fetch, port }, () => {
|
|
60
|
+
currentServer = server;
|
|
61
|
+
currentPort = port;
|
|
55
62
|
process.stderr.write(`postino GUI: http://localhost:${port}\n`);
|
|
56
63
|
});
|
|
57
64
|
server.on("error", (err) => {
|
|
@@ -69,3 +76,33 @@ export function startWebServer(port, attempt = 0) {
|
|
|
69
76
|
process.stderr.write(`postino GUI failed to start: ${err}\n`);
|
|
70
77
|
}
|
|
71
78
|
}
|
|
79
|
+
export function restartOnPort(port) {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
try {
|
|
82
|
+
const server = serve({ fetch: app.fetch, port }, () => {
|
|
83
|
+
// New server started successfully, close the old one
|
|
84
|
+
const oldServer = currentServer;
|
|
85
|
+
currentServer = server;
|
|
86
|
+
currentPort = port;
|
|
87
|
+
process.stderr.write(`postino GUI: takeover http://localhost:${port}\n`);
|
|
88
|
+
if (oldServer) {
|
|
89
|
+
oldServer.close();
|
|
90
|
+
}
|
|
91
|
+
resolve(true);
|
|
92
|
+
});
|
|
93
|
+
server.on("error", (err) => {
|
|
94
|
+
if (err.code === "EADDRINUSE") {
|
|
95
|
+
process.stderr.write(`postino: takeover port ${port} already claimed\n`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
process.stderr.write(`postino: takeover failed: ${err.message}\n`);
|
|
99
|
+
}
|
|
100
|
+
// Old server stays running, state unchanged
|
|
101
|
+
resolve(false);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
resolve(false);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|