patchcord 0.4.2 → 0.5.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 +1 -1
- package/bin/patchcord.mjs +11 -11
- package/commands/subscribe.toml +1 -0
- package/package.json +1 -1
- package/scripts/lib/ws.mjs +229 -0
- package/scripts/subscribe.mjs +299 -0
- package/skills/subscribe/SKILL.md +80 -0
package/bin/patchcord.mjs
CHANGED
|
@@ -185,12 +185,12 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd === "--token" || cmd ===
|
|
|
185
185
|
let cursorChanged = false;
|
|
186
186
|
if (!existsSync(cursorSkillDir)) {
|
|
187
187
|
mkdirSync(cursorSkillDir, { recursive: true });
|
|
188
|
-
cpSync(join(pluginRoot, "skills", "
|
|
188
|
+
cpSync(join(pluginRoot, "skills", "inbox", "SKILL.md"), join(cursorSkillDir, "SKILL.md"));
|
|
189
189
|
cursorChanged = true;
|
|
190
190
|
}
|
|
191
191
|
if (!existsSync(cursorWaitDir)) {
|
|
192
192
|
mkdirSync(cursorWaitDir, { recursive: true });
|
|
193
|
-
cpSync(join(pluginRoot, "skills", "
|
|
193
|
+
cpSync(join(pluginRoot, "skills", "wait", "SKILL.md"), join(cursorWaitDir, "SKILL.md"));
|
|
194
194
|
cursorChanged = true;
|
|
195
195
|
}
|
|
196
196
|
if (cursorChanged) globalChanges.push("Cursor skills installed");
|
|
@@ -203,12 +203,12 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd === "--token" || cmd ===
|
|
|
203
203
|
let windsurfChanged = false;
|
|
204
204
|
if (!existsSync(windsurfSkillDir)) {
|
|
205
205
|
mkdirSync(windsurfSkillDir, { recursive: true });
|
|
206
|
-
cpSync(join(pluginRoot, "skills", "
|
|
206
|
+
cpSync(join(pluginRoot, "skills", "inbox", "SKILL.md"), join(windsurfSkillDir, "SKILL.md"));
|
|
207
207
|
windsurfChanged = true;
|
|
208
208
|
}
|
|
209
209
|
if (!existsSync(windsurfWaitDir)) {
|
|
210
210
|
mkdirSync(windsurfWaitDir, { recursive: true });
|
|
211
|
-
cpSync(join(pluginRoot, "skills", "
|
|
211
|
+
cpSync(join(pluginRoot, "skills", "wait", "SKILL.md"), join(windsurfWaitDir, "SKILL.md"));
|
|
212
212
|
windsurfChanged = true;
|
|
213
213
|
}
|
|
214
214
|
if (windsurfChanged) globalChanges.push("Windsurf skills installed");
|
|
@@ -222,12 +222,12 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd === "--token" || cmd ===
|
|
|
222
222
|
let geminiChanged = false;
|
|
223
223
|
if (!existsSync(geminiSkillDir)) {
|
|
224
224
|
mkdirSync(geminiSkillDir, { recursive: true });
|
|
225
|
-
cpSync(join(pluginRoot, "skills", "
|
|
225
|
+
cpSync(join(pluginRoot, "skills", "inbox", "SKILL.md"), join(geminiSkillDir, "SKILL.md"));
|
|
226
226
|
geminiChanged = true;
|
|
227
227
|
}
|
|
228
228
|
if (!existsSync(geminiWaitDir)) {
|
|
229
229
|
mkdirSync(geminiWaitDir, { recursive: true });
|
|
230
|
-
cpSync(join(pluginRoot, "skills", "
|
|
230
|
+
cpSync(join(pluginRoot, "skills", "wait", "SKILL.md"), join(geminiWaitDir, "SKILL.md"));
|
|
231
231
|
geminiChanged = true;
|
|
232
232
|
}
|
|
233
233
|
if (!existsSync(join(geminiCmdDir, "inbox.toml"))) {
|
|
@@ -779,8 +779,8 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd === "--token" || cmd ===
|
|
|
779
779
|
const agWaitDir = join(agDir, "skills", "patchcord-wait");
|
|
780
780
|
mkdirSync(agSkillDir, { recursive: true });
|
|
781
781
|
mkdirSync(agWaitDir, { recursive: true });
|
|
782
|
-
cpSync(join(pluginRoot, "skills", "
|
|
783
|
-
cpSync(join(pluginRoot, "skills", "
|
|
782
|
+
cpSync(join(pluginRoot, "skills", "inbox", "SKILL.md"), join(agSkillDir, "SKILL.md"));
|
|
783
|
+
cpSync(join(pluginRoot, "skills", "wait", "SKILL.md"), join(agWaitDir, "SKILL.md"));
|
|
784
784
|
console.log(` ${green}✓${r} Skills installed: ${dim}patchcord${r}, ${dim}patchcord-wait${r}`);
|
|
785
785
|
console.log(` ${yellow}Global config — all Antigravity projects share this agent.${r}`);
|
|
786
786
|
} else if (isCline) {
|
|
@@ -883,7 +883,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd === "--token" || cmd ===
|
|
|
883
883
|
const waitDest = join(cwd, ".agents", "skills", "patchcord-wait");
|
|
884
884
|
mkdirSync(waitDest, { recursive: true });
|
|
885
885
|
writeFileSync(join(waitDest, "SKILL.md"),
|
|
886
|
-
readFileSync(join(pluginRoot, "skills", "
|
|
886
|
+
readFileSync(join(pluginRoot, "skills", "wait", "SKILL.md"), "utf-8"));
|
|
887
887
|
|
|
888
888
|
const codexDir = join(cwd, ".codex");
|
|
889
889
|
mkdirSync(codexDir, { recursive: true });
|
|
@@ -945,7 +945,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd === "--token" || cmd ===
|
|
|
945
945
|
writeFileSync(join(pluginDir, "skills", "patchcord", "SKILL.md"),
|
|
946
946
|
readFileSync(join(pluginRoot, "per-project-skills", "codex", "SKILL.md"), "utf-8"));
|
|
947
947
|
writeFileSync(join(pluginDir, "skills", "patchcord-wait", "SKILL.md"),
|
|
948
|
-
readFileSync(join(pluginRoot, "skills", "
|
|
948
|
+
readFileSync(join(pluginRoot, "skills", "wait", "SKILL.md"), "utf-8"));
|
|
949
949
|
|
|
950
950
|
// Personal marketplace entry (relative path from marketplace root)
|
|
951
951
|
const marketplacePath = join(marketplaceDir, "marketplace.json");
|
|
@@ -971,7 +971,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd === "--token" || cmd ===
|
|
|
971
971
|
const globalWaitDir = join(homedir(), ".agents", "skills", "patchcord-wait");
|
|
972
972
|
mkdirSync(globalWaitDir, { recursive: true });
|
|
973
973
|
writeFileSync(join(globalWaitDir, "SKILL.md"),
|
|
974
|
-
readFileSync(join(pluginRoot, "skills", "
|
|
974
|
+
readFileSync(join(pluginRoot, "skills", "wait", "SKILL.md"), "utf-8"));
|
|
975
975
|
|
|
976
976
|
console.log(`\n ${green}✓${r} Codex configured: ${dim}${configPath}${r}`);
|
|
977
977
|
console.log(` ${green}✓${r} Plugin installed: ${dim}@patchcord${r}, ${dim}@patchcord-wait${r}`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prompt = "Start the Patchcord background listener per the patchcord:subscribe skill. Spawn scripts/subscribe.mjs with run_in_background, attach the Monitor tool to its stdout, and when a 'PATCHCORD:' line appears, announce it briefly and call inbox() to handle any new messages."
|
package/package.json
CHANGED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Minimal WebSocket client (RFC 6455 subset).
|
|
2
|
+
// Zero dependencies, stdlib only — TLS + HTTP/1.1 Upgrade + text frames.
|
|
3
|
+
// Designed for Supabase Realtime: single-frame text messages, no extensions,
|
|
4
|
+
// no fragmentation, no binary.
|
|
5
|
+
|
|
6
|
+
import { connect as tlsConnect } from "node:tls";
|
|
7
|
+
import { connect as netConnect } from "node:net";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { EventEmitter } from "node:events";
|
|
10
|
+
import { URL } from "node:url";
|
|
11
|
+
|
|
12
|
+
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
13
|
+
|
|
14
|
+
export function connect(urlStr, { headers = {} } = {}) {
|
|
15
|
+
const emitter = new EventEmitter();
|
|
16
|
+
const url = new URL(urlStr);
|
|
17
|
+
const isTls = url.protocol === "wss:";
|
|
18
|
+
const port = url.port ? Number(url.port) : isTls ? 443 : 80;
|
|
19
|
+
const key = randomBytes(16).toString("base64");
|
|
20
|
+
const expectedAccept = createHash("sha1").update(key + GUID).digest("base64");
|
|
21
|
+
|
|
22
|
+
const path = url.pathname + (url.search || "") || "/";
|
|
23
|
+
const host = url.host;
|
|
24
|
+
|
|
25
|
+
const reqHeaders = [
|
|
26
|
+
`GET ${path} HTTP/1.1`,
|
|
27
|
+
`Host: ${host}`,
|
|
28
|
+
"Upgrade: websocket",
|
|
29
|
+
"Connection: Upgrade",
|
|
30
|
+
`Sec-WebSocket-Key: ${key}`,
|
|
31
|
+
"Sec-WebSocket-Version: 13",
|
|
32
|
+
];
|
|
33
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
34
|
+
reqHeaders.push(`${k}: ${v}`);
|
|
35
|
+
}
|
|
36
|
+
const req = reqHeaders.join("\r\n") + "\r\n\r\n";
|
|
37
|
+
|
|
38
|
+
const socket = isTls
|
|
39
|
+
? tlsConnect({ host: url.hostname, port, servername: url.hostname })
|
|
40
|
+
: netConnect({ host: url.hostname, port });
|
|
41
|
+
|
|
42
|
+
let handshakeDone = false;
|
|
43
|
+
let buf = Buffer.alloc(0);
|
|
44
|
+
let closed = false;
|
|
45
|
+
|
|
46
|
+
const close = (code = 1000, reason = "") => {
|
|
47
|
+
if (closed) return;
|
|
48
|
+
closed = true;
|
|
49
|
+
try {
|
|
50
|
+
socket.write(encodeFrame(0x8, closePayload(code, reason), true));
|
|
51
|
+
} catch (_) {}
|
|
52
|
+
socket.end();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const send = (str) => {
|
|
56
|
+
if (closed) throw new Error("WebSocket closed");
|
|
57
|
+
const payload = Buffer.from(str, "utf8");
|
|
58
|
+
socket.write(encodeFrame(0x1, payload, true));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
socket.on("error", (err) => {
|
|
62
|
+
if (!closed) {
|
|
63
|
+
closed = true;
|
|
64
|
+
emitter.emit("error", err);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
socket.on("close", () => {
|
|
69
|
+
if (!closed) {
|
|
70
|
+
closed = true;
|
|
71
|
+
emitter.emit("close");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const onConnect = () => {
|
|
76
|
+
socket.write(req);
|
|
77
|
+
};
|
|
78
|
+
if (isTls) socket.on("secureConnect", onConnect);
|
|
79
|
+
else socket.on("connect", onConnect);
|
|
80
|
+
|
|
81
|
+
socket.on("data", (chunk) => {
|
|
82
|
+
buf = Buffer.concat([buf, chunk]);
|
|
83
|
+
if (!handshakeDone) {
|
|
84
|
+
const idx = buf.indexOf("\r\n\r\n");
|
|
85
|
+
if (idx === -1) return;
|
|
86
|
+
const header = buf.slice(0, idx).toString("utf8");
|
|
87
|
+
buf = buf.slice(idx + 4);
|
|
88
|
+
const lines = header.split("\r\n");
|
|
89
|
+
const status = lines[0];
|
|
90
|
+
if (!/^HTTP\/1\.1 101/.test(status)) {
|
|
91
|
+
emitter.emit("error", new Error(`handshake failed: ${status}`));
|
|
92
|
+
close();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const accept = lines
|
|
96
|
+
.find((l) => /^sec-websocket-accept:/i.test(l))
|
|
97
|
+
?.split(":")[1]
|
|
98
|
+
?.trim();
|
|
99
|
+
if (accept !== expectedAccept) {
|
|
100
|
+
emitter.emit("error", new Error("invalid Sec-WebSocket-Accept"));
|
|
101
|
+
close();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
handshakeDone = true;
|
|
105
|
+
emitter.emit("open");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Parse as many frames as we have data for
|
|
109
|
+
while (buf.length >= 2) {
|
|
110
|
+
const parsed = decodeFrame(buf);
|
|
111
|
+
if (parsed === null) break; // need more data
|
|
112
|
+
const { opcode, payload, consumed } = parsed;
|
|
113
|
+
buf = buf.slice(consumed);
|
|
114
|
+
|
|
115
|
+
if (opcode === 0x1) {
|
|
116
|
+
emitter.emit("message", payload.toString("utf8"));
|
|
117
|
+
} else if (opcode === 0x8) {
|
|
118
|
+
// close frame
|
|
119
|
+
emitter.emit("close");
|
|
120
|
+
closed = true;
|
|
121
|
+
try {
|
|
122
|
+
socket.write(encodeFrame(0x8, closePayload(1000, ""), true));
|
|
123
|
+
} catch (_) {}
|
|
124
|
+
socket.end();
|
|
125
|
+
return;
|
|
126
|
+
} else if (opcode === 0x9) {
|
|
127
|
+
// ping → pong
|
|
128
|
+
socket.write(encodeFrame(0xa, payload, true));
|
|
129
|
+
} else if (opcode === 0xa) {
|
|
130
|
+
// pong — ignore
|
|
131
|
+
} else {
|
|
132
|
+
// unsupported opcode (binary/continuation/extensions) → close
|
|
133
|
+
emitter.emit("error", new Error(`unsupported opcode 0x${opcode.toString(16)}`));
|
|
134
|
+
close(1003, "unsupported");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
emitter.send = send;
|
|
141
|
+
emitter.close = close;
|
|
142
|
+
return emitter;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function encodeFrame(opcode, payload, mask) {
|
|
146
|
+
const len = payload.length;
|
|
147
|
+
let header;
|
|
148
|
+
if (len < 126) {
|
|
149
|
+
header = Buffer.from([0x80 | opcode, (mask ? 0x80 : 0) | len]);
|
|
150
|
+
} else if (len < 65536) {
|
|
151
|
+
header = Buffer.alloc(4);
|
|
152
|
+
header[0] = 0x80 | opcode;
|
|
153
|
+
header[1] = (mask ? 0x80 : 0) | 126;
|
|
154
|
+
header.writeUInt16BE(len, 2);
|
|
155
|
+
} else {
|
|
156
|
+
header = Buffer.alloc(10);
|
|
157
|
+
header[0] = 0x80 | opcode;
|
|
158
|
+
header[1] = (mask ? 0x80 : 0) | 127;
|
|
159
|
+
// Write big-endian 64-bit length. JS safe-integer limit (2^53) is well
|
|
160
|
+
// above anything we'll ever send over this client.
|
|
161
|
+
header.writeUInt32BE(0, 2);
|
|
162
|
+
header.writeUInt32BE(len, 6);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!mask) return Buffer.concat([header, payload]);
|
|
166
|
+
|
|
167
|
+
const maskKey = randomBytes(4);
|
|
168
|
+
const masked = Buffer.alloc(len);
|
|
169
|
+
for (let i = 0; i < len; i++) {
|
|
170
|
+
masked[i] = payload[i] ^ maskKey[i % 4];
|
|
171
|
+
}
|
|
172
|
+
return Buffer.concat([header, maskKey, masked]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function decodeFrame(buf) {
|
|
176
|
+
if (buf.length < 2) return null;
|
|
177
|
+
const b0 = buf[0];
|
|
178
|
+
const b1 = buf[1];
|
|
179
|
+
const fin = (b0 & 0x80) !== 0;
|
|
180
|
+
const opcode = b0 & 0x0f;
|
|
181
|
+
const masked = (b1 & 0x80) !== 0;
|
|
182
|
+
let len = b1 & 0x7f;
|
|
183
|
+
let offset = 2;
|
|
184
|
+
|
|
185
|
+
if (len === 126) {
|
|
186
|
+
if (buf.length < offset + 2) return null;
|
|
187
|
+
len = buf.readUInt16BE(offset);
|
|
188
|
+
offset += 2;
|
|
189
|
+
} else if (len === 127) {
|
|
190
|
+
if (buf.length < offset + 8) return null;
|
|
191
|
+
const hi = buf.readUInt32BE(offset);
|
|
192
|
+
const lo = buf.readUInt32BE(offset + 4);
|
|
193
|
+
len = hi * 0x100000000 + lo;
|
|
194
|
+
offset += 8;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let maskKey = null;
|
|
198
|
+
if (masked) {
|
|
199
|
+
if (buf.length < offset + 4) return null;
|
|
200
|
+
maskKey = buf.slice(offset, offset + 4);
|
|
201
|
+
offset += 4;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (buf.length < offset + len) return null;
|
|
205
|
+
|
|
206
|
+
let payload = buf.slice(offset, offset + len);
|
|
207
|
+
if (maskKey) {
|
|
208
|
+
const unmasked = Buffer.alloc(len);
|
|
209
|
+
for (let i = 0; i < len; i++) {
|
|
210
|
+
unmasked[i] = payload[i] ^ maskKey[i % 4];
|
|
211
|
+
}
|
|
212
|
+
payload = unmasked;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!fin) {
|
|
216
|
+
// Reject fragmentation — Supabase Realtime never fragments.
|
|
217
|
+
throw new Error("fragmented frames not supported");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { opcode, payload, consumed: offset + len };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function closePayload(code, reason) {
|
|
224
|
+
const reasonBuf = Buffer.from(reason, "utf8");
|
|
225
|
+
const payload = Buffer.alloc(2 + reasonBuf.length);
|
|
226
|
+
payload.writeUInt16BE(code, 0);
|
|
227
|
+
reasonBuf.copy(payload, 2);
|
|
228
|
+
return payload;
|
|
229
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Patchcord subscribe: background listener that wakes Claude when new
|
|
3
|
+
// messages arrive for this agent. Connects to Supabase Realtime via
|
|
4
|
+
// WebSocket, prints one line to stdout per incoming INSERT event.
|
|
5
|
+
//
|
|
6
|
+
// Launched by the /patchcord:subscribe skill with run_in_background.
|
|
7
|
+
// Claude Code's Monitor tool watches our stdout and surfaces each
|
|
8
|
+
// "PATCHCORD: ..." line as a notification.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
|
|
11
|
+
import { request as httpsRequest } from "node:https";
|
|
12
|
+
import { request as httpRequest } from "node:http";
|
|
13
|
+
import { URL } from "node:url";
|
|
14
|
+
import { connect as wsConnect } from "./lib/ws.mjs";
|
|
15
|
+
|
|
16
|
+
const JWT_REFRESH_SAFETY_MARGIN_SEC = 120;
|
|
17
|
+
const HEARTBEAT_INTERVAL_MS = 25_000;
|
|
18
|
+
const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 15_000, 30_000];
|
|
19
|
+
|
|
20
|
+
function die(msg, code = 1) {
|
|
21
|
+
process.stderr.write(msg + "\n");
|
|
22
|
+
process.exit(code);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readMcpConfig(cwd) {
|
|
26
|
+
const path = `${cwd}/.mcp.json`;
|
|
27
|
+
if (!existsSync(path)) die(`no .mcp.json in ${cwd}`);
|
|
28
|
+
let json;
|
|
29
|
+
try {
|
|
30
|
+
json = JSON.parse(readFileSync(path, "utf8"));
|
|
31
|
+
} catch (e) {
|
|
32
|
+
die(`.mcp.json parse error: ${e.message}`);
|
|
33
|
+
}
|
|
34
|
+
const pc = json?.mcpServers?.patchcord;
|
|
35
|
+
if (!pc?.url || !pc?.headers?.Authorization) {
|
|
36
|
+
die(".mcp.json missing mcpServers.patchcord.url or Authorization");
|
|
37
|
+
}
|
|
38
|
+
let baseUrl = pc.url;
|
|
39
|
+
// Strip known MCP path suffixes to get the API base
|
|
40
|
+
baseUrl = baseUrl.replace(/\/mcp\/bearer$/, "").replace(/\/mcp$/, "");
|
|
41
|
+
const auth = pc.headers.Authorization;
|
|
42
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth;
|
|
43
|
+
return { baseUrl, token };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function httpJson(urlStr, { method = "GET", headers = {}, body = null } = {}) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const url = new URL(urlStr);
|
|
49
|
+
const lib = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
50
|
+
const req = lib(
|
|
51
|
+
{
|
|
52
|
+
method,
|
|
53
|
+
hostname: url.hostname,
|
|
54
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
55
|
+
path: url.pathname + (url.search || ""),
|
|
56
|
+
headers,
|
|
57
|
+
},
|
|
58
|
+
(res) => {
|
|
59
|
+
let chunks = "";
|
|
60
|
+
res.setEncoding("utf8");
|
|
61
|
+
res.on("data", (c) => (chunks += c));
|
|
62
|
+
res.on("end", () => resolve({ status: res.statusCode, body: chunks }));
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
req.on("error", reject);
|
|
66
|
+
if (body) req.write(body);
|
|
67
|
+
req.end();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function fetchTicket(baseUrl, token) {
|
|
72
|
+
const res = await httpJson(`${baseUrl}/api/realtime/ticket`, {
|
|
73
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
74
|
+
});
|
|
75
|
+
if (res.status === 401 || res.status === 403) {
|
|
76
|
+
die(`ticket: token rejected (HTTP ${res.status}) — check .mcp.json`);
|
|
77
|
+
}
|
|
78
|
+
if (res.status === 501) {
|
|
79
|
+
die("ticket: server not configured for realtime (self-hosted without Supabase?)");
|
|
80
|
+
}
|
|
81
|
+
if (res.status === 404) {
|
|
82
|
+
die("ticket: namespace not owned — regenerate your token");
|
|
83
|
+
}
|
|
84
|
+
if (res.status !== 200) {
|
|
85
|
+
throw new Error(`ticket HTTP ${res.status}: ${res.body.slice(0, 200)}`);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(res.body);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
throw new Error(`ticket: bad JSON: ${e.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writePidfile(path) {
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(path, String(process.pid), { flag: "wx" });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (e.code === "EEXIST") {
|
|
99
|
+
// Check if the PID is alive
|
|
100
|
+
try {
|
|
101
|
+
const existingPid = Number(readFileSync(path, "utf8").trim());
|
|
102
|
+
if (existingPid && existingPid !== process.pid) {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(existingPid, 0);
|
|
105
|
+
die(`already running (pid ${existingPid})`, 2);
|
|
106
|
+
} catch (_) {
|
|
107
|
+
// stale
|
|
108
|
+
try {
|
|
109
|
+
unlinkSync(path);
|
|
110
|
+
} catch (_) {}
|
|
111
|
+
writeFileSync(path, String(process.pid), { flag: "wx" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch (_) {
|
|
116
|
+
try {
|
|
117
|
+
unlinkSync(path);
|
|
118
|
+
} catch (_) {}
|
|
119
|
+
writeFileSync(path, String(process.pid), { flag: "wx" });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function removePidfile(path) {
|
|
129
|
+
try {
|
|
130
|
+
unlinkSync(path);
|
|
131
|
+
} catch (_) {}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function run() {
|
|
135
|
+
const cwd = process.cwd();
|
|
136
|
+
const { baseUrl, token } = readMcpConfig(cwd);
|
|
137
|
+
process.stderr.write(`subscribe: cwd=${cwd} server=${baseUrl}\n`);
|
|
138
|
+
|
|
139
|
+
let ticket = await fetchTicket(baseUrl, token);
|
|
140
|
+
const pidfile = `/tmp/patchcord_subscribe_${ticket.namespace_ids[0]}_${ticket.agent_id}.pid`;
|
|
141
|
+
writePidfile(pidfile);
|
|
142
|
+
|
|
143
|
+
const cleanup = () => removePidfile(pidfile);
|
|
144
|
+
process.on("exit", cleanup);
|
|
145
|
+
process.on("SIGINT", () => {
|
|
146
|
+
cleanup();
|
|
147
|
+
process.exit(0);
|
|
148
|
+
});
|
|
149
|
+
process.on("SIGTERM", () => {
|
|
150
|
+
cleanup();
|
|
151
|
+
process.exit(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
process.stderr.write(
|
|
155
|
+
`subscribe: agent=${ticket.agent_id} namespaces=${ticket.namespace_ids.join(",")}\n`
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
let backoffIdx = 0;
|
|
159
|
+
|
|
160
|
+
const loop = async () => {
|
|
161
|
+
while (true) {
|
|
162
|
+
try {
|
|
163
|
+
await runOnce(ticket, baseUrl, token, async () => {
|
|
164
|
+
ticket = await fetchTicket(baseUrl, token);
|
|
165
|
+
return ticket;
|
|
166
|
+
});
|
|
167
|
+
backoffIdx = 0; // clean disconnect resets backoff
|
|
168
|
+
} catch (e) {
|
|
169
|
+
process.stderr.write(`subscribe: ${e.message}\n`);
|
|
170
|
+
}
|
|
171
|
+
const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
|
|
172
|
+
backoffIdx++;
|
|
173
|
+
process.stderr.write(`subscribe: reconnecting in ${delay}ms\n`);
|
|
174
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
175
|
+
try {
|
|
176
|
+
ticket = await fetchTicket(baseUrl, token);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
process.stderr.write(`subscribe: ticket refresh failed: ${e.message}\n`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
await loop();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const allowedNs = new Set(ticket.namespace_ids);
|
|
189
|
+
const wsUrl = `${ticket.realtime_url}?apikey=${encodeURIComponent(ticket.apikey)}&vsn=1.0.0`;
|
|
190
|
+
const ws = wsConnect(wsUrl);
|
|
191
|
+
|
|
192
|
+
let ref = 1;
|
|
193
|
+
let heartbeatTimer = null;
|
|
194
|
+
let refreshTimer = null;
|
|
195
|
+
let currentJwt = ticket.jwt;
|
|
196
|
+
let settled = false;
|
|
197
|
+
|
|
198
|
+
const done = (err) => {
|
|
199
|
+
if (settled) return;
|
|
200
|
+
settled = true;
|
|
201
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
202
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
203
|
+
try {
|
|
204
|
+
ws.close();
|
|
205
|
+
} catch (_) {}
|
|
206
|
+
if (err) reject(err);
|
|
207
|
+
else resolve();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
ws.on("open", () => {
|
|
211
|
+
process.stderr.write("subscribe: connected\n");
|
|
212
|
+
for (const topic of ticket.topics) {
|
|
213
|
+
ws.send(
|
|
214
|
+
JSON.stringify({
|
|
215
|
+
topic: topic.name,
|
|
216
|
+
event: "phx_join",
|
|
217
|
+
payload: {
|
|
218
|
+
config: topic.config,
|
|
219
|
+
access_token: currentJwt,
|
|
220
|
+
},
|
|
221
|
+
ref: String(ref++),
|
|
222
|
+
})
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
heartbeatTimer = setInterval(() => {
|
|
226
|
+
try {
|
|
227
|
+
ws.send(
|
|
228
|
+
JSON.stringify({
|
|
229
|
+
topic: "phoenix",
|
|
230
|
+
event: "heartbeat",
|
|
231
|
+
payload: {},
|
|
232
|
+
ref: String(ref++),
|
|
233
|
+
})
|
|
234
|
+
);
|
|
235
|
+
} catch (_) {}
|
|
236
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
237
|
+
|
|
238
|
+
const refreshIn = Math.max(
|
|
239
|
+
(ticket.jwt_expires_in - JWT_REFRESH_SAFETY_MARGIN_SEC) * 1000,
|
|
240
|
+
30_000
|
|
241
|
+
);
|
|
242
|
+
refreshTimer = setTimeout(async () => {
|
|
243
|
+
try {
|
|
244
|
+
const fresh = await refreshTicket();
|
|
245
|
+
currentJwt = fresh.jwt;
|
|
246
|
+
for (const topic of fresh.topics) {
|
|
247
|
+
ws.send(
|
|
248
|
+
JSON.stringify({
|
|
249
|
+
topic: topic.name,
|
|
250
|
+
event: "access_token",
|
|
251
|
+
payload: { access_token: currentJwt },
|
|
252
|
+
ref: String(ref++),
|
|
253
|
+
})
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
process.stderr.write("subscribe: token refreshed\n");
|
|
257
|
+
} catch (e) {
|
|
258
|
+
process.stderr.write(`subscribe: token refresh failed: ${e.message}\n`);
|
|
259
|
+
done(e);
|
|
260
|
+
}
|
|
261
|
+
}, refreshIn);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
ws.on("message", (raw) => {
|
|
265
|
+
let frame;
|
|
266
|
+
try {
|
|
267
|
+
frame = JSON.parse(raw);
|
|
268
|
+
} catch {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (frame.event !== "postgres_changes") return;
|
|
272
|
+
const data = frame.payload?.data;
|
|
273
|
+
if (!data || data.type !== "INSERT") return;
|
|
274
|
+
const rec = data.record;
|
|
275
|
+
if (!rec) return;
|
|
276
|
+
// Defense-in-depth: verify the row's namespace_id is in our scope.
|
|
277
|
+
// RLS already enforces this server-side, but if policies drift we
|
|
278
|
+
// don't want to leak cross-namespace notifications.
|
|
279
|
+
if (rec.namespace_id && !allowedNs.has(rec.namespace_id)) return;
|
|
280
|
+
const from = rec.from_agent || "unknown";
|
|
281
|
+
process.stdout.write(`PATCHCORD: 1 new from ${from}\n`);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
ws.on("error", (err) => {
|
|
285
|
+
process.stderr.write(`subscribe: ws error: ${err.message}\n`);
|
|
286
|
+
done(err);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
ws.on("close", () => {
|
|
290
|
+
process.stderr.write("subscribe: ws closed\n");
|
|
291
|
+
done();
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
run().catch((e) => {
|
|
297
|
+
process.stderr.write(`subscribe: fatal: ${e.message}\n`);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: patchcord:subscribe
|
|
3
|
+
description: >
|
|
4
|
+
Start a background listener that wakes Claude the moment a new Patchcord
|
|
5
|
+
message arrives for this agent. Uses Supabase Realtime over WebSocket —
|
|
6
|
+
zero polling, zero idle cost. Use when the user says "subscribe",
|
|
7
|
+
"listen for patchcord messages", "wake me when messages arrive", or runs
|
|
8
|
+
/patchcord:subscribe.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# What this does
|
|
12
|
+
|
|
13
|
+
Spawns `scripts/subscribe.mjs` in the background. The script holds a
|
|
14
|
+
WebSocket to Supabase Realtime and prints one line to stdout per new
|
|
15
|
+
`agent_messages` INSERT for this agent. Claude Code's Monitor tool picks
|
|
16
|
+
up each line as a notification; Claude wakes up and calls `inbox()`.
|
|
17
|
+
|
|
18
|
+
No polling, no tokens burned while idle. The process stays alive until
|
|
19
|
+
the user kills it or closes the Claude Code session.
|
|
20
|
+
|
|
21
|
+
# Starting
|
|
22
|
+
|
|
23
|
+
1. Find namespace + agent_id. Call `mcp__patchcord__inbox` if you don't
|
|
24
|
+
already know them from this session. The response contains `namespace_id`
|
|
25
|
+
and `agent_id`.
|
|
26
|
+
2. Compute pidfile: `/tmp/patchcord_subscribe_<namespace_id>_<agent_id>.pid`.
|
|
27
|
+
3. Check if a listener is already running:
|
|
28
|
+
- If the pidfile exists AND `kill -0 $(cat <pidfile>)` succeeds →
|
|
29
|
+
tell the user "Patchcord listener already active (pid N)" and stop.
|
|
30
|
+
Do NOT spawn another one.
|
|
31
|
+
- If the pidfile exists but the PID is dead → the subscribe script
|
|
32
|
+
itself cleans up stale pidfiles on startup, so just proceed.
|
|
33
|
+
4. Find the script at `$CLAUDE_PLUGIN_ROOT/scripts/subscribe.mjs`.
|
|
34
|
+
5. Run it in the background with Bash `run_in_background: true`:
|
|
35
|
+
```
|
|
36
|
+
node "$CLAUDE_PLUGIN_ROOT/scripts/subscribe.mjs"
|
|
37
|
+
```
|
|
38
|
+
6. Attach the `Monitor` tool to that background shell so its stdout
|
|
39
|
+
becomes a stream of notifications.
|
|
40
|
+
7. Tell the user one short line:
|
|
41
|
+
"Patchcord listener active — I'll pick up new messages as they arrive."
|
|
42
|
+
|
|
43
|
+
# When a notification fires
|
|
44
|
+
|
|
45
|
+
Monitor surfaces a line like `PATCHCORD: 1 new from backend`. Do this:
|
|
46
|
+
|
|
47
|
+
1. Say one brief line in chat so the user can see you got pinged:
|
|
48
|
+
"Got a Patchcord ping from <sender> — checking inbox."
|
|
49
|
+
2. Call `mcp__patchcord__inbox`.
|
|
50
|
+
3. For each pending message: do the work first (follow the
|
|
51
|
+
patchcord:inbox skill), then reply with what you did.
|
|
52
|
+
4. Return to listening — Monitor keeps running.
|
|
53
|
+
|
|
54
|
+
# Stopping
|
|
55
|
+
|
|
56
|
+
There is no `/patchcord:unsubscribe` command. Tell the user either:
|
|
57
|
+
|
|
58
|
+
- Close this Claude Code session (the background process will keep
|
|
59
|
+
running unless they kill it — see below), OR
|
|
60
|
+
- Run `kill $(cat /tmp/patchcord_subscribe_<namespace>_<agent>.pid)` in
|
|
61
|
+
a terminal.
|
|
62
|
+
|
|
63
|
+
# If it fails to start
|
|
64
|
+
|
|
65
|
+
The script exits 1 with a clear stderr message in these cases:
|
|
66
|
+
|
|
67
|
+
- `no .mcp.json in <cwd>` — the Claude session is not in a patchcord
|
|
68
|
+
project directory.
|
|
69
|
+
- `token rejected` — the bearer in `.mcp.json` is bad; regenerate from
|
|
70
|
+
the dashboard.
|
|
71
|
+
- `server not configured for realtime` — the server hasn't had
|
|
72
|
+
`SUPABASE_JWT_SECRET` / `SUPABASE_ANON_KEY` set. This is a cloud-only
|
|
73
|
+
feature for now. Tell the user.
|
|
74
|
+
- `namespace not owned` — the token's namespace lost its owner row;
|
|
75
|
+
regenerate from the dashboard.
|
|
76
|
+
- `already running (pid N)` — another subscribe is already active.
|
|
77
|
+
Report that to the user, do not try again.
|
|
78
|
+
|
|
79
|
+
In all of these, report the exact error to the user and stop — don't
|
|
80
|
+
loop or retry.
|