@manuelfedele/postino 0.1.1 → 0.2.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/.claude-plugin/marketplace.json +19 -0
- package/README.md +80 -16
- package/dist/index.js +58 -4
- package/dist/valkey.d.ts +1 -0
- package/dist/valkey.js +5 -0
- package/dist/web/api.js +3 -1
- package/dist/web/server.d.ts +6 -0
- package/dist/web/server.js +49 -0
- package/package.json +1 -1
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "postino",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Manuel Fedele",
|
|
5
|
+
"url": "https://github.com/manuelfedele"
|
|
6
|
+
},
|
|
7
|
+
"plugins": [
|
|
8
|
+
{
|
|
9
|
+
"name": "postino",
|
|
10
|
+
"description": "Inter-agent messaging, broadcasts, and real-time web GUI for Claude Code. Let your agents talk to each other across tabs and processes.",
|
|
11
|
+
"category": "productivity",
|
|
12
|
+
"source": {
|
|
13
|
+
"source": "url",
|
|
14
|
+
"url": "https://github.com/manuelfedele/postino.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/manuelfedele/postino"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
package/README.md
CHANGED
|
@@ -35,11 +35,22 @@
|
|
|
35
35
|
|
|
36
36
|
## Quick Start
|
|
37
37
|
|
|
38
|
+
### As a Claude Code plugin (recommended)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
claude plugin marketplace add manuelfedele/postino
|
|
42
|
+
claude plugin install postino
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or from within Claude Code: `/plugin marketplace add manuelfedele/postino` then `/plugin install postino`.
|
|
46
|
+
|
|
47
|
+
### Via npx
|
|
48
|
+
|
|
38
49
|
```bash
|
|
39
50
|
npx @manuelfedele/postino install
|
|
40
51
|
```
|
|
41
52
|
|
|
42
|
-
|
|
53
|
+
Restart Claude Code after either method. Your agent is online.
|
|
43
54
|
|
|
44
55
|
> **Prerequisite:** Valkey or Redis running on `localhost:6379`
|
|
45
56
|
|
|
@@ -122,28 +133,81 @@ Updates in real-time via Server-Sent Events. When an agent sends a message from
|
|
|
122
133
|
|
|
123
134
|
## How It Works
|
|
124
135
|
|
|
136
|
+
### 1-to-1 Messaging
|
|
137
|
+
|
|
138
|
+
Messages work like a queue: send pushes, read pops.
|
|
139
|
+
|
|
140
|
+
```mermaid
|
|
141
|
+
sequenceDiagram
|
|
142
|
+
participant A as Tab 1 (agent-A)
|
|
143
|
+
participant V as Valkey
|
|
144
|
+
participant B as Tab 2 (agent-B)
|
|
145
|
+
|
|
146
|
+
A->>V: msg_send(to=B, "run tests")
|
|
147
|
+
V-->>B: pub/sub notify
|
|
148
|
+
Note over B: hook fires on next prompt
|
|
149
|
+
B->>V: msg_check()
|
|
150
|
+
V-->>B: "1 unread message"
|
|
151
|
+
B->>V: msg_read()
|
|
152
|
+
V-->>B: [{from: A, body: "run tests"}]
|
|
153
|
+
Note over V: message consumed
|
|
125
154
|
```
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
155
|
+
|
|
156
|
+
### Broadcasts
|
|
157
|
+
|
|
158
|
+
Broadcasts are shared. Every agent reads independently via a per-agent cursor.
|
|
159
|
+
|
|
160
|
+
```mermaid
|
|
161
|
+
sequenceDiagram
|
|
162
|
+
participant A as Tab 1 (agent-A)
|
|
163
|
+
participant V as Valkey
|
|
164
|
+
participant B as Tab 2 (agent-B)
|
|
165
|
+
participant C as Tab 3 (agent-C)
|
|
166
|
+
|
|
167
|
+
A->>V: msg_broadcast("deploy freeze")
|
|
168
|
+
V-->>B: SSE event
|
|
169
|
+
V-->>C: SSE event
|
|
170
|
+
B->>V: msg_broadcasts()
|
|
171
|
+
V-->>B: [{from: A, body: "deploy freeze"}]
|
|
172
|
+
Note over V: cursor advanced for B
|
|
173
|
+
C->>V: msg_broadcasts()
|
|
174
|
+
V-->>C: [{from: A, body: "deploy freeze"}]
|
|
175
|
+
Note over V: cursor advanced for C
|
|
176
|
+
Note over V: message still exists (TTL expiry)
|
|
138
177
|
```
|
|
139
178
|
|
|
140
|
-
|
|
179
|
+
### Architecture
|
|
180
|
+
|
|
181
|
+
```mermaid
|
|
182
|
+
graph LR
|
|
183
|
+
subgraph Claude Code
|
|
184
|
+
T1[Tab 1<br/>MCP client] -->|stdio| M1[Postino<br/>MCP server]
|
|
185
|
+
T2[Tab 2<br/>MCP client] -->|stdio| M2[Postino<br/>MCP server]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
M1 -->|ioredis| VK[(Valkey)]
|
|
189
|
+
M2 -->|ioredis| VK
|
|
190
|
+
|
|
191
|
+
M1 -->|Hono :3333| GUI[Web GUI]
|
|
192
|
+
VK -->|pub/sub| GUI
|
|
193
|
+
GUI -->|SSE| Browser
|
|
194
|
+
|
|
195
|
+
H[Hook<br/>check-messages.sh] -->|curl /api/check| M1
|
|
196
|
+
|
|
197
|
+
style VK fill:#e63030,color:#fff,stroke:none
|
|
198
|
+
style GUI fill:#2563eb,color:#fff,stroke:none
|
|
199
|
+
style H fill:#d97706,color:#fff,stroke:none
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Under the Hood
|
|
203
|
+
|
|
204
|
+
**Messages** are Valkey lists (one per inbox). `msg_send` pushes, `msg_read` pops. Unread messages expire after 24h (configurable).
|
|
141
205
|
|
|
142
|
-
**Broadcasts** are a shared Valkey list. Each agent tracks a cursor (last-seen index). Reading
|
|
206
|
+
**Broadcasts** are a shared Valkey list. Each agent tracks a cursor (last-seen index). Reading advances the cursor without deleting, so every agent sees every broadcast.
|
|
143
207
|
|
|
144
208
|
**Agent presence** uses Valkey keys with a 30-second TTL, refreshed by a heartbeat. If a process dies, it goes offline within 30 seconds.
|
|
145
209
|
|
|
146
|
-
**The hook** (`UserPromptSubmit`) calls `GET /api/check/:agent` via curl.
|
|
210
|
+
**The hook** (`UserPromptSubmit`) calls `GET /api/check/:agent` via curl. Zero output when there's nothing new (zero token cost). One-line hint when messages arrive.
|
|
147
211
|
|
|
148
212
|
---
|
|
149
213
|
|
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,16 +15,70 @@ 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 {
|
|
49
|
+
await connect();
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const url = config.valkeyUrl;
|
|
53
|
+
process.stderr.write(`\n postino: cannot connect to Valkey/Redis at ${url}\n\n`);
|
|
54
|
+
process.stderr.write(` Postino requires Valkey or Redis. Start one with:\n\n`);
|
|
55
|
+
process.stderr.write(` docker run -d --name valkey -p 6379:6379 valkey/valkey:8\n\n`);
|
|
56
|
+
process.stderr.write(` Or install natively:\n\n`);
|
|
57
|
+
process.stderr.write(` macOS: brew install valkey && brew services start valkey\n`);
|
|
58
|
+
process.stderr.write(` Linux: apt install valkey-server\n\n`);
|
|
59
|
+
process.stderr.write(` To use a different host/port:\n\n`);
|
|
60
|
+
process.stderr.write(` POSTINO_VALKEY_URL=redis://host:port\n\n`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
20
63
|
await registerAgent(config.agentName);
|
|
21
64
|
const transport = new StdioServerTransport();
|
|
22
65
|
await server.connect(transport);
|
|
23
66
|
if (config.webEnabled) {
|
|
24
67
|
startWebServer(config.webPort);
|
|
68
|
+
subscribeGuiTakeover();
|
|
25
69
|
}
|
|
26
70
|
process.stderr.write(`postino agent: ${config.agentName}\n`);
|
|
27
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
|
+
}
|
|
28
82
|
await deregisterAgent(config.agentName);
|
|
29
83
|
await disconnect();
|
|
30
84
|
process.exit(0);
|
|
@@ -33,6 +87,6 @@ async function main() {
|
|
|
33
87
|
process.on("SIGTERM", shutdown);
|
|
34
88
|
}
|
|
35
89
|
main().catch((err) => {
|
|
36
|
-
|
|
90
|
+
process.stderr.write(`postino: ${err.message || err}\n`);
|
|
37
91
|
process.exit(1);
|
|
38
92
|
});
|
package/dist/valkey.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ 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>;
|
package/dist/valkey.js
CHANGED
|
@@ -5,10 +5,12 @@ export const valkey = new Redis(config.valkeyUrl, {
|
|
|
5
5
|
lazyConnect: true,
|
|
6
6
|
maxRetriesPerRequest: 3,
|
|
7
7
|
});
|
|
8
|
+
valkey.on("error", () => { }); // Handled in connect()
|
|
8
9
|
export const valkeySub = new Redis(config.valkeyUrl, {
|
|
9
10
|
lazyConnect: true,
|
|
10
11
|
maxRetriesPerRequest: 3,
|
|
11
12
|
});
|
|
13
|
+
valkeySub.on("error", () => { }); // Handled in connect()
|
|
12
14
|
const prefix = config.keyPrefix;
|
|
13
15
|
export const keys = {
|
|
14
16
|
inbox: (agent) => `${prefix}inbox:${agent}`,
|
|
@@ -18,9 +20,12 @@ export const keys = {
|
|
|
18
20
|
broadcastCursor: (agent) => `${prefix}bcursor:${agent}`,
|
|
19
21
|
notifyChannel: (agent) => `${prefix}notify:${agent}`,
|
|
20
22
|
eventsChannel: () => `${prefix}events`,
|
|
23
|
+
guiTakeoverChannel: () => `${prefix}gui:takeover`,
|
|
21
24
|
};
|
|
22
25
|
export async function connect() {
|
|
23
26
|
await valkey.connect();
|
|
27
|
+
// Verify the connection actually works
|
|
28
|
+
await valkey.ping();
|
|
24
29
|
await valkeySub.connect();
|
|
25
30
|
}
|
|
26
31
|
export async function disconnect() {
|
package/dist/web/api.js
CHANGED
|
@@ -101,7 +101,9 @@ function ensureEventSubscription() {
|
|
|
101
101
|
valkeySub.subscribe(keys.eventsChannel()).catch(() => {
|
|
102
102
|
subscribedToEvents = false;
|
|
103
103
|
});
|
|
104
|
-
valkeySub.on("message", (
|
|
104
|
+
valkeySub.on("message", (channel, message) => {
|
|
105
|
+
if (channel !== keys.eventsChannel())
|
|
106
|
+
return;
|
|
105
107
|
for (const client of sseClients) {
|
|
106
108
|
try {
|
|
107
109
|
client.send(message);
|
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,45 @@ 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
|
+
const oldServer = currentServer;
|
|
82
|
+
const oldPort = currentPort;
|
|
83
|
+
const launchNew = () => {
|
|
84
|
+
try {
|
|
85
|
+
const server = serve({ fetch: app.fetch, port }, () => {
|
|
86
|
+
currentServer = server;
|
|
87
|
+
currentPort = port;
|
|
88
|
+
process.stderr.write(`postino GUI: takeover http://localhost:${port}\n`);
|
|
89
|
+
resolve(true);
|
|
90
|
+
});
|
|
91
|
+
server.on("error", (err) => {
|
|
92
|
+
if (err.code === "EADDRINUSE") {
|
|
93
|
+
process.stderr.write(`postino: takeover port ${port} already claimed\n`);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
process.stderr.write(`postino: takeover failed: ${err.message}\n`);
|
|
97
|
+
}
|
|
98
|
+
// Restore old state if we had one
|
|
99
|
+
currentServer = oldServer;
|
|
100
|
+
currentPort = oldPort;
|
|
101
|
+
resolve(false);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
currentServer = oldServer;
|
|
106
|
+
currentPort = oldPort;
|
|
107
|
+
resolve(false);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
if (oldServer) {
|
|
111
|
+
// Close existing server first, then start on the new port
|
|
112
|
+
currentServer = null;
|
|
113
|
+
currentPort = null;
|
|
114
|
+
oldServer.close(() => launchNew());
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
launchNew();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|