patchcord 0.5.2 → 0.5.4
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/package.json +1 -1
- package/scripts/lib/ws.mjs +11 -3
- package/scripts/subscribe.mjs +44 -10
- package/skills/subscribe/SKILL.md +48 -28
package/package.json
CHANGED
package/scripts/lib/ws.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function connect(urlStr, { headers = {} } = {}) {
|
|
|
68
68
|
socket.on("close", () => {
|
|
69
69
|
if (!closed) {
|
|
70
70
|
closed = true;
|
|
71
|
-
emitter.emit("close");
|
|
71
|
+
emitter.emit("close", { code: null, reason: "socket-ended" });
|
|
72
72
|
}
|
|
73
73
|
});
|
|
74
74
|
|
|
@@ -115,8 +115,16 @@ export function connect(urlStr, { headers = {} } = {}) {
|
|
|
115
115
|
if (opcode === 0x1) {
|
|
116
116
|
emitter.emit("message", payload.toString("utf8"));
|
|
117
117
|
} else if (opcode === 0x8) {
|
|
118
|
-
// close frame
|
|
119
|
-
|
|
118
|
+
// close frame — parse code/reason for diagnostics
|
|
119
|
+
let code = null;
|
|
120
|
+
let reason = "";
|
|
121
|
+
if (payload.length >= 2) {
|
|
122
|
+
code = payload.readUInt16BE(0);
|
|
123
|
+
if (payload.length > 2) {
|
|
124
|
+
reason = payload.slice(2).toString("utf8");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
emitter.emit("close", { code, reason });
|
|
120
128
|
closed = true;
|
|
121
129
|
try {
|
|
122
130
|
socket.write(encodeFrame(0x8, closePayload(1000, ""), true));
|
package/scripts/subscribe.mjs
CHANGED
|
@@ -17,6 +17,17 @@ const JWT_REFRESH_SAFETY_MARGIN_SEC = 120;
|
|
|
17
17
|
const HEARTBEAT_INTERVAL_MS = 25_000;
|
|
18
18
|
const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 15_000, 30_000];
|
|
19
19
|
|
|
20
|
+
// Guarantee a terminal stderr line on any unhandled failure so the agent
|
|
21
|
+
// reading Monitor's output file always sees WHY the process died.
|
|
22
|
+
process.on("uncaughtException", (err) => {
|
|
23
|
+
process.stderr.write(`subscribe: fatal: uncaught: ${err?.stack || err?.message || err}\n`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
26
|
+
process.on("unhandledRejection", (err) => {
|
|
27
|
+
process.stderr.write(`subscribe: fatal: unhandled rejection: ${err?.stack || err?.message || err}\n`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
20
31
|
function die(msg, code = 1) {
|
|
21
32
|
process.stderr.write(msg + "\n");
|
|
22
33
|
process.exit(code);
|
|
@@ -235,14 +246,29 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
235
246
|
} catch (_) {}
|
|
236
247
|
}, HEARTBEAT_INTERVAL_MS);
|
|
237
248
|
|
|
238
|
-
const
|
|
239
|
-
(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
249
|
+
const scheduleRefresh = (ttlSec) => {
|
|
250
|
+
const refreshIn = Math.max((ttlSec - JWT_REFRESH_SAFETY_MARGIN_SEC) * 1000, 30_000);
|
|
251
|
+
refreshTimer = setTimeout(doRefresh, refreshIn);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const doRefresh = async () => {
|
|
255
|
+
if (settled) return;
|
|
243
256
|
try {
|
|
244
257
|
const fresh = await refreshTicket();
|
|
245
258
|
currentJwt = fresh.jwt;
|
|
259
|
+
// Socket-level auth update (phoenix topic) — what Supabase
|
|
260
|
+
// actually uses for the connection's own JWT expiry check.
|
|
261
|
+
// Without this, the server closes the socket at the original
|
|
262
|
+
// JWT's exp regardless of per-channel updates.
|
|
263
|
+
ws.send(
|
|
264
|
+
JSON.stringify({
|
|
265
|
+
topic: "phoenix",
|
|
266
|
+
event: "access_token",
|
|
267
|
+
payload: { access_token: currentJwt },
|
|
268
|
+
ref: String(ref++),
|
|
269
|
+
})
|
|
270
|
+
);
|
|
271
|
+
// Channel-level updates — matches supabase-js's setAuth() pattern.
|
|
246
272
|
for (const topic of fresh.topics) {
|
|
247
273
|
ws.send(
|
|
248
274
|
JSON.stringify({
|
|
@@ -254,11 +280,17 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
254
280
|
);
|
|
255
281
|
}
|
|
256
282
|
process.stderr.write("subscribe: token refreshed\n");
|
|
283
|
+
scheduleRefresh(fresh.jwt_expires_in);
|
|
257
284
|
} catch (e) {
|
|
258
|
-
|
|
259
|
-
|
|
285
|
+
// Transient network/server error — do NOT close the live
|
|
286
|
+
// connection. The current JWT is still valid for ~2 more min
|
|
287
|
+
// (JWT_REFRESH_SAFETY_MARGIN_SEC). Retry sooner.
|
|
288
|
+
process.stderr.write(`subscribe: token refresh failed, retrying in 30s: ${e.message}\n`);
|
|
289
|
+
refreshTimer = setTimeout(doRefresh, 30_000);
|
|
260
290
|
}
|
|
261
|
-
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
scheduleRefresh(ticket.jwt_expires_in);
|
|
262
294
|
});
|
|
263
295
|
|
|
264
296
|
ws.on("message", (raw) => {
|
|
@@ -286,8 +318,10 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
286
318
|
done(err);
|
|
287
319
|
});
|
|
288
320
|
|
|
289
|
-
ws.on("close", () => {
|
|
290
|
-
|
|
321
|
+
ws.on("close", (info) => {
|
|
322
|
+
const codeStr = info?.code != null ? `code=${info.code}` : "code=none";
|
|
323
|
+
const reasonStr = info?.reason ? ` reason=${JSON.stringify(info.reason)}` : "";
|
|
324
|
+
process.stderr.write(`subscribe: ws closed (${codeStr}${reasonStr})\n`);
|
|
291
325
|
done();
|
|
292
326
|
});
|
|
293
327
|
});
|
|
@@ -68,19 +68,19 @@ then the script is at
|
|
|
68
68
|
description: "patchcord realtime listener (<agent>@<ns>)",
|
|
69
69
|
persistent: true,
|
|
70
70
|
timeout_ms: 3600000,
|
|
71
|
-
command: "exec node \"<absolute-path-to-subscribe.mjs>\"
|
|
71
|
+
command: "exec node \"<absolute-path-to-subscribe.mjs>\""
|
|
72
72
|
)
|
|
73
73
|
```
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
No `2>&1`, no grep filter. By construction the script only writes
|
|
75
|
+
`PATCHCORD: ...` lines to stdout. Everything else — `connected`,
|
|
76
|
+
`token refreshed`, startup diagnostics, errors — goes to stderr,
|
|
77
|
+
which Monitor captures into its output file but does NOT fire as
|
|
78
|
+
notifications. So there's nothing to filter and no way to get the
|
|
79
|
+
filter wrong.
|
|
80
80
|
|
|
81
81
|
Crash detection is handled automatically by Monitor itself: when the
|
|
82
|
-
|
|
83
|
-
notification with the
|
|
82
|
+
process exits, Monitor emits a built-in "stream ended" task
|
|
83
|
+
notification with the output file path and exit code.
|
|
84
84
|
|
|
85
85
|
6. **Tell the user one short line:**
|
|
86
86
|
"Patchcord listener active — I'll pick up new messages as they arrive."
|
|
@@ -103,25 +103,45 @@ There is no `/patchcord:unsubscribe` command. Tell the user either:
|
|
|
103
103
|
- Run `kill $(cat /tmp/patchcord_subscribe_<namespace>_<agent>.pid)` in
|
|
104
104
|
a terminal.
|
|
105
105
|
|
|
106
|
-
# If the Monitor stream ends
|
|
106
|
+
# If the Monitor stream ends — STRICT PROTOCOL
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
The stream-end task notification includes the path to Monitor's output
|
|
109
|
+
file. Do exactly this, in this order:
|
|
110
|
+
|
|
111
|
+
1. Read that output file using the Read tool.
|
|
112
|
+
2. Look at the last ~15 lines for a line matching one of the known
|
|
113
|
+
failure strings below. There will always be at least one terminal
|
|
114
|
+
error line — the script's error handlers guarantee it.
|
|
115
|
+
3. Report the specific cause to the user in one short sentence.
|
|
116
|
+
4. STOP.
|
|
117
|
+
|
|
118
|
+
**Forbidden on failure — do not do any of these:**
|
|
119
|
+
- Do NOT run `pgrep`, `ps`, `kill`, `pkill`, `killall`, or any command
|
|
120
|
+
that targets PIDs or process names.
|
|
121
|
+
- Do NOT modify, delete, or write to the pidfile yourself. The script
|
|
122
|
+
manages it; it's already cleaned up by the time Monitor emits the
|
|
123
|
+
stream-end event.
|
|
124
|
+
- Do NOT spawn another Monitor or another `node subscribe.mjs`. One
|
|
125
|
+
failure means something is wrong with the config or environment;
|
|
126
|
+
respawning will not fix it and will make things worse.
|
|
127
|
+
- Do NOT search for orphaned processes or try to "clean up" state.
|
|
128
|
+
|
|
129
|
+
Concrete failure strings you may see and what they mean:
|
|
112
130
|
|
|
113
131
|
- `no .mcp.json in <cwd>` — session is not in a patchcord project dir.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
Tell the user which directory to `cd` into.
|
|
133
|
+
- `ticket: token rejected (HTTP 401|403)` — bearer in `.mcp.json` is
|
|
134
|
+
bad; regenerate from the dashboard.
|
|
135
|
+
- `ticket: server not configured for realtime` — the patchcord server
|
|
136
|
+
hasn't had `SUPABASE_JWT_SECRET` / `SUPABASE_ANON_KEY` set. This is
|
|
137
|
+
a cloud-only feature.
|
|
138
|
+
- `ticket: namespace not owned — regenerate your token` — the token's
|
|
139
|
+
namespace lost its owner row; regenerate from the dashboard.
|
|
140
|
+
- `already running (pid N)` (exit code 2) — pidfile guard tripped,
|
|
141
|
+
another listener is active. Report and stop. Do NOT kill the other
|
|
142
|
+
listener to make room.
|
|
143
|
+
- `subscribe: fatal: ...` — unhandled error. Show the user the line
|
|
144
|
+
verbatim, stop.
|
|
145
|
+
|
|
146
|
+
If the process exited cleanly (exit 0) with no error line, the user
|
|
147
|
+
closed the session or killed the process. Nothing to do.
|