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.
@@ -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.5.2",
4
+ "version": "0.5.4",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -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
- emitter.emit("close");
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));
@@ -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 refreshIn = Math.max(
239
- (ticket.jwt_expires_in - JWT_REFRESH_SAFETY_MARGIN_SEC) * 1000,
240
- 30_000
241
- );
242
- refreshTimer = setTimeout(async () => {
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
- process.stderr.write(`subscribe: token refresh failed: ${e.message}\n`);
259
- done(e);
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
- }, refreshIn);
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
- process.stderr.write("subscribe: ws closed\n");
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>\" 2>&1 | grep --line-buffered '^PATCHCORD:'"
71
+ command: "exec node \"<absolute-path-to-subscribe.mjs>\""
72
72
  )
73
73
  ```
74
- The filter is deliberately narrow: **only** `PATCHCORD:` lines
75
- (actual message arrivals) become notifications. Everything else the
76
- script writes`connected`, `token refreshed`, `cwd=...`,
77
- `agent=...`, `reconnecting in Nms` is plumbing the user doesn't
78
- need to see. Those lines still land in Monitor's output file, so you
79
- can Read them on demand if something looks off.
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
- `node` process exits, you get a built-in "stream ended" task
83
- notification with the exit code. No filter needed for that.
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
- That means the subscribe process exited. Before telling the user
109
- anything, Read the Monitor output file (the path is in the stream-end
110
- task notification) to see the last stderr lines. The concrete failure
111
- strings:
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
- - `token rejected (HTTP 401|403)` bearer in `.mcp.json` is bad;
115
- regenerate from the dashboard.
116
- - `server not configured for realtime` — server hasn't had
117
- `SUPABASE_JWT_SECRET` / `SUPABASE_ANON_KEY` set. Self-hosted without
118
- Supabase does not support this feature yet.
119
- - `namespace not owned` — the token's namespace lost its owner row;
120
- regenerate from the dashboard.
121
- - `already running (pid N)` (exit code 2) — pidfile guard tripped.
122
- Another subscribe is active. Report and stop.
123
-
124
- Report the specific cause to the user, do not loop or retry. If the
125
- process exited cleanly after many successful reconnects with no error
126
- line, that's either a session close or the user killed it — no action
127
- needed.
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.