patchcord 0.5.3 → 0.5.5

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.3",
4
+ "version": "0.5.5",
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.3",
3
+ "version": "0.5.5",
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));
@@ -102,6 +102,27 @@ async function fetchTicket(baseUrl, token) {
102
102
  }
103
103
  }
104
104
 
105
+ // Check if there are messages already pending in the inbox at the moment
106
+ // we connect (or reconnect). Realtime only delivers FUTURE INSERTs, so
107
+ // anything queued before we joined is invisible until the agent calls
108
+ // inbox() manually. Emit a stdout line when there's a pending queue so
109
+ // Monitor wakes the agent the same way a real arrival does.
110
+ async function drainQueueOnce(baseUrl, token) {
111
+ const res = await httpJson(`${baseUrl}/api/inbox?count_only=1&limit=100`, {
112
+ headers: { Authorization: `Bearer ${token}` },
113
+ });
114
+ if (res.status !== 200) {
115
+ throw new Error(`inbox HTTP ${res.status}`);
116
+ }
117
+ let count = 0;
118
+ try {
119
+ count = JSON.parse(res.body).pending_count ?? 0;
120
+ } catch (_) {}
121
+ if (count > 0) {
122
+ process.stdout.write(`PATCHCORD: ${count} waiting in inbox\n`);
123
+ }
124
+ }
125
+
105
126
  function writePidfile(path) {
106
127
  try {
107
128
  writeFileSync(path, String(process.pid), { flag: "wx" });
@@ -233,6 +254,16 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
233
254
  })
234
255
  );
235
256
  }
257
+
258
+ // Drain any messages already in the queue when we connected.
259
+ // Realtime only delivers FUTURE INSERTs — anything pending before
260
+ // we joined (or that arrived during a reconnect gap) wouldn't
261
+ // otherwise wake the agent. Fire-and-forget: a transient HTTP
262
+ // failure here just means we miss queued messages this round;
263
+ // the next reconnect retries.
264
+ drainQueueOnce(baseUrl, token).catch((e) => {
265
+ process.stderr.write(`subscribe: queue check failed: ${e.message}\n`);
266
+ });
236
267
  heartbeatTimer = setInterval(() => {
237
268
  try {
238
269
  ws.send(
@@ -246,14 +277,29 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
246
277
  } catch (_) {}
247
278
  }, HEARTBEAT_INTERVAL_MS);
248
279
 
249
- const refreshIn = Math.max(
250
- (ticket.jwt_expires_in - JWT_REFRESH_SAFETY_MARGIN_SEC) * 1000,
251
- 30_000
252
- );
253
- refreshTimer = setTimeout(async () => {
280
+ const scheduleRefresh = (ttlSec) => {
281
+ const refreshIn = Math.max((ttlSec - JWT_REFRESH_SAFETY_MARGIN_SEC) * 1000, 30_000);
282
+ refreshTimer = setTimeout(doRefresh, refreshIn);
283
+ };
284
+
285
+ const doRefresh = async () => {
286
+ if (settled) return;
254
287
  try {
255
288
  const fresh = await refreshTicket();
256
289
  currentJwt = fresh.jwt;
290
+ // Socket-level auth update (phoenix topic) — what Supabase
291
+ // actually uses for the connection's own JWT expiry check.
292
+ // Without this, the server closes the socket at the original
293
+ // JWT's exp regardless of per-channel updates.
294
+ ws.send(
295
+ JSON.stringify({
296
+ topic: "phoenix",
297
+ event: "access_token",
298
+ payload: { access_token: currentJwt },
299
+ ref: String(ref++),
300
+ })
301
+ );
302
+ // Channel-level updates — matches supabase-js's setAuth() pattern.
257
303
  for (const topic of fresh.topics) {
258
304
  ws.send(
259
305
  JSON.stringify({
@@ -265,11 +311,17 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
265
311
  );
266
312
  }
267
313
  process.stderr.write("subscribe: token refreshed\n");
314
+ scheduleRefresh(fresh.jwt_expires_in);
268
315
  } catch (e) {
269
- process.stderr.write(`subscribe: token refresh failed: ${e.message}\n`);
270
- done(e);
316
+ // Transient network/server error — do NOT close the live
317
+ // connection. The current JWT is still valid for ~2 more min
318
+ // (JWT_REFRESH_SAFETY_MARGIN_SEC). Retry sooner.
319
+ process.stderr.write(`subscribe: token refresh failed, retrying in 30s: ${e.message}\n`);
320
+ refreshTimer = setTimeout(doRefresh, 30_000);
271
321
  }
272
- }, refreshIn);
322
+ };
323
+
324
+ scheduleRefresh(ticket.jwt_expires_in);
273
325
  });
274
326
 
275
327
  ws.on("message", (raw) => {
@@ -297,8 +349,10 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
297
349
  done(err);
298
350
  });
299
351
 
300
- ws.on("close", () => {
301
- process.stderr.write("subscribe: ws closed\n");
352
+ ws.on("close", (info) => {
353
+ const codeStr = info?.code != null ? `code=${info.code}` : "code=none";
354
+ const reasonStr = info?.reason ? ` reason=${JSON.stringify(info.reason)}` : "";
355
+ process.stderr.write(`subscribe: ws closed (${codeStr}${reasonStr})\n`);
302
356
  done();
303
357
  });
304
358
  });