patchcord 0.4.3 → 0.5.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "patchcord",
3
3
  "description": "Cross-machine agent messaging with push delivery. Messages from other agents arrive as native channel notifications.",
4
- "version": "0.3.98",
4
+ "version": "0.5.1",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -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,114 @@
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
16
+ picks up each line as a notification; Claude wakes up and calls
17
+ `inbox()`.
18
+
19
+ No polling, no tokens burned while idle. The process stays alive until
20
+ the user kills it or closes the Claude Code session.
21
+
22
+ # How to find the script path (read carefully — this is the one thing that trips agents up)
23
+
24
+ At the top of the skill invocation message, Claude Code shows a header:
25
+ `Base directory for this skill: <ABSOLUTE_PATH>/skills/subscribe`
26
+
27
+ Take that path, strip `/skills/subscribe` from the end — you now have the
28
+ plugin root. The script is at `<PLUGIN_ROOT>/scripts/subscribe.mjs`.
29
+
30
+ **Do not rely on `$CLAUDE_PLUGIN_ROOT`** — it is often unset inside
31
+ the Bash shell even when the skill is running. Always derive the path
32
+ from the "Base directory for this skill" header you were given.
33
+
34
+ Example: if the header says
35
+ `Base directory for this skill: /home/user/.npm/_npx/abc123/node_modules/patchcord/skills/subscribe`
36
+ then the script is at
37
+ `/home/user/.npm/_npx/abc123/node_modules/patchcord/scripts/subscribe.mjs`.
38
+
39
+ # Starting (step by step)
40
+
41
+ 1. **Know your identity.** If you don't already have `namespace_id` and
42
+ `agent_id` from this session, call `mcp__patchcord__inbox` once — the
43
+ response starts with `<agent>@<namespace> | N pending` and you can
44
+ read both off that line.
45
+
46
+ 2. **Compute the pidfile path:**
47
+ `/tmp/patchcord_subscribe_<namespace_id>_<agent_id>.pid`
48
+
49
+ 3. **Check for an existing listener.** One Bash call:
50
+ ```bash
51
+ PF=/tmp/patchcord_subscribe_<ns>_<agent>.pid
52
+ if [ -f "$PF" ] && kill -0 "$(cat "$PF")" 2>/dev/null; then
53
+ echo "ALREADY_RUNNING pid=$(cat "$PF")"
54
+ else
55
+ echo "OK_TO_SPAWN"
56
+ fi
57
+ ```
58
+ If output is `ALREADY_RUNNING`, tell the user "Patchcord listener
59
+ already active (pid N)" and STOP. Do not spawn another one.
60
+
61
+ 4. **Resolve the script path** using the recipe above.
62
+
63
+ 5. **Spawn under Monitor** — not Bash with `run_in_background`. Monitor
64
+ is the right tool because every stdout line becomes a notification.
65
+ Example call shape:
66
+ ```
67
+ Monitor(
68
+ description: "patchcord realtime listener (<agent>@<ns>)",
69
+ persistent: true,
70
+ timeout_ms: 3600000,
71
+ command: "exec node \"<absolute-path-to-subscribe.mjs>\" 2>&1 | grep --line-buffered -E '^PATCHCORD:|^subscribe: (fatal|ws error|token|already|connected|reconnecting|cwd|agent)'"
72
+ )
73
+ ```
74
+ The `grep` filter is intentional — it surfaces the signal lines
75
+ (`PATCHCORD:` arrivals, connect/disconnect, errors) and drops the
76
+ noise. The filter catches every terminal/state-change event, so the
77
+ Monitor won't silently miss a crash.
78
+
79
+ 6. **Tell the user one short line:**
80
+ "Patchcord listener active — I'll pick up new messages as they arrive."
81
+
82
+ # When a notification fires
83
+
84
+ Monitor surfaces `PATCHCORD: 1 new from <sender>`. Do this:
85
+
86
+ 1. Say one brief line: "Got a Patchcord ping from <sender> — checking inbox."
87
+ 2. Call `mcp__patchcord__inbox`.
88
+ 3. For each pending message, do the work first (follow the
89
+ patchcord:inbox skill), then reply with what you did.
90
+ 4. Return to listening — Monitor keeps running.
91
+
92
+ # Stopping
93
+
94
+ There is no `/patchcord:unsubscribe` command. Tell the user either:
95
+
96
+ - Close this Claude Code session, OR
97
+ - Run `kill $(cat /tmp/patchcord_subscribe_<namespace>_<agent>.pid)` in
98
+ a terminal.
99
+
100
+ # If it fails to start
101
+
102
+ Stderr shows the exact cause. Report it to the user verbatim and stop
103
+ — do not loop or retry:
104
+
105
+ - `no .mcp.json in <cwd>` — session is not in a patchcord project dir.
106
+ - `token rejected` — bearer in `.mcp.json` is bad; regenerate from the
107
+ dashboard.
108
+ - `server not configured for realtime` — server hasn't had
109
+ `SUPABASE_JWT_SECRET` / `SUPABASE_ANON_KEY` set. Self-hosted without
110
+ Supabase does not support this feature yet.
111
+ - `namespace not owned` — the token's namespace lost its owner row;
112
+ regenerate from the dashboard.
113
+ - `already running (pid N)` — pidfile guard tripped. Another subscribe
114
+ is active. Report and stop.