patchcord 0.5.59 → 0.5.61

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.5.59",
3
+ "version": "0.5.61",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -22,6 +22,11 @@ const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 15_000, 30_000];
22
22
  // gap means three missed heartbeats. Force a reconnect via the outer loop.
23
23
  const FRESHNESS_CHECK_INTERVAL_MS = 30_000;
24
24
  const FRESHNESS_STALE_MS = 90_000;
25
+ // Re-emit pending count every minute as a safety net. The Realtime push
26
+ // + Monitor pipeline can drop notifications when an agent is mid-tool-call
27
+ // (especially for long-running work like vector-DB searches). Re-emitting
28
+ // gives Monitor multiple chances to surface the line on a later idle tick.
29
+ const PENDING_HEARTBEAT_MS = 60_000;
25
30
 
26
31
  // Short HH:MM:SS prefix so the Monitor output can be scanned at a glance.
27
32
  // Local time — Monitor's reader is always a human looking at one machine.
@@ -243,6 +248,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
243
248
  let heartbeatTimer = null;
244
249
  let refreshTimer = null;
245
250
  let freshnessTimer = null;
251
+ let pendingHeartbeatTimer = null;
246
252
  let lastEventAt = Date.now();
247
253
  let currentJwt = ticket.jwt;
248
254
  let settled = false;
@@ -253,6 +259,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
253
259
  if (heartbeatTimer) clearInterval(heartbeatTimer);
254
260
  if (refreshTimer) clearTimeout(refreshTimer);
255
261
  if (freshnessTimer) clearInterval(freshnessTimer);
262
+ if (pendingHeartbeatTimer) clearInterval(pendingHeartbeatTimer);
256
263
  try {
257
264
  ws.close();
258
265
  } catch (_) {}
@@ -311,6 +318,20 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
311
318
  }
312
319
  }, FRESHNESS_CHECK_INTERVAL_MS);
313
320
 
321
+ // Pending-inbox heartbeat. The Realtime INSERT push that triggers the
322
+ // initial "PATCHCORD: 1 new from <sender>" notification can be lost
323
+ // by the Monitor → agent-context surface layer if the agent happens
324
+ // to be mid-tool-call when the line is emitted (heavy agents with
325
+ // long tool calls — vector-DB search, CrossRef lookups — are most
326
+ // exposed). Re-emit the pending count once a minute as long as the
327
+ // inbox is non-empty so a later idle tick has another chance to
328
+ // wake the agent. drainQueueOnce stays silent if pending_count == 0.
329
+ pendingHeartbeatTimer = setInterval(() => {
330
+ drainQueueOnce(baseUrl, token).catch((e) => {
331
+ logErr(`subscribe: pending heartbeat failed: ${e.message}`);
332
+ });
333
+ }, PENDING_HEARTBEAT_MS);
334
+
314
335
  const scheduleRefresh = (ttlSec) => {
315
336
  const refreshIn = Math.max((ttlSec - JWT_REFRESH_SAFETY_MARGIN_SEC) * 1000, 30_000);
316
337
  refreshTimer = setTimeout(doRefresh, refreshIn);
@@ -372,6 +393,43 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
372
393
  } catch {
373
394
  return;
374
395
  }
396
+
397
+ // Surface channel-state failures so silent server-side channel
398
+ // termination becomes visible. Previously we discarded every
399
+ // non-postgres_changes frame, which masked phx_join rejections,
400
+ // channel_error system messages, and post-refresh access_token
401
+ // rejections — exactly the cases where the WS stays "open" but
402
+ // realtime delivery is dead. Force a reconnect on any of those.
403
+ if (frame.event === "phx_reply") {
404
+ const status = frame.payload?.status;
405
+ if (status === "error") {
406
+ const reason = frame.payload?.response?.reason || frame.payload?.response || "unknown";
407
+ logErr(`subscribe: phx_reply error on topic=${frame.topic} ref=${frame.ref}: ${JSON.stringify(reason)}`);
408
+ done(new Error(`phx_reply error: ${JSON.stringify(reason)}`));
409
+ return;
410
+ }
411
+ return;
412
+ }
413
+ if (frame.event === "system") {
414
+ const status = frame.payload?.status;
415
+ const message = frame.payload?.message || frame.payload?.extension || "";
416
+ if (status === "error" || status === "channel_error") {
417
+ logErr(`subscribe: system error on topic=${frame.topic}: ${message}`);
418
+ done(new Error(`system error: ${message}`));
419
+ return;
420
+ }
421
+ // Successful subscribe ack — log once for confirmation, then quiet.
422
+ if (status === "ok" && message) {
423
+ logErr(`subscribe: system ok on ${frame.topic}: ${message}`);
424
+ }
425
+ return;
426
+ }
427
+ if (frame.event === "phx_error" || frame.event === "phx_close") {
428
+ logErr(`subscribe: ${frame.event} on topic=${frame.topic}: ${JSON.stringify(frame.payload || {})}`);
429
+ done(new Error(`${frame.event} on ${frame.topic}`));
430
+ return;
431
+ }
432
+
375
433
  if (frame.event !== "postgres_changes") return;
376
434
  const data = frame.payload?.data;
377
435
  if (!data || data.type !== "INSERT") return;