patchcord 0.5.14 → 0.5.15

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.14",
3
+ "version": "0.5.15",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -16,6 +16,12 @@ import { connect as wsConnect } from "./lib/ws.mjs";
16
16
  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
+ // Freshness watchdog: if we haven't received ANY frame from the server
20
+ // (heartbeat replies, postgres_changes, system events) for this long, the
21
+ // WS is silently dead — phx replies should arrive every ~25s, so a 90s
22
+ // gap means three missed heartbeats. Force a reconnect via the outer loop.
23
+ const FRESHNESS_CHECK_INTERVAL_MS = 30_000;
24
+ const FRESHNESS_STALE_MS = 90_000;
19
25
 
20
26
  // Guarantee a terminal stderr line on any unhandled failure so the agent
21
27
  // reading Monitor's output file always sees WHY the process died.
@@ -230,6 +236,8 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
230
236
  let ref = 1;
231
237
  let heartbeatTimer = null;
232
238
  let refreshTimer = null;
239
+ let freshnessTimer = null;
240
+ let lastEventAt = Date.now();
233
241
  let currentJwt = ticket.jwt;
234
242
  let settled = false;
235
243
 
@@ -238,6 +246,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
238
246
  settled = true;
239
247
  if (heartbeatTimer) clearInterval(heartbeatTimer);
240
248
  if (refreshTimer) clearTimeout(refreshTimer);
249
+ if (freshnessTimer) clearInterval(freshnessTimer);
241
250
  try {
242
251
  ws.close();
243
252
  } catch (_) {}
@@ -283,6 +292,21 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
283
292
  } catch (_) {}
284
293
  }, HEARTBEAT_INTERVAL_MS);
285
294
 
295
+ // Freshness watchdog. Phoenix replies to every heartbeat with a
296
+ // phx_reply frame; if those stop arriving (silent network drop,
297
+ // backgrounded socket after laptop sleep, server-side eviction
298
+ // without close frame), the WS appears open but is dead. Force a
299
+ // reconnect via the outer loop — done(err) increments backoffIdx.
300
+ freshnessTimer = setInterval(() => {
301
+ const stale = Date.now() - lastEventAt;
302
+ if (stale > FRESHNESS_STALE_MS) {
303
+ process.stderr.write(
304
+ `subscribe: ws stale ${Math.round(stale / 1000)}s, forcing reconnect\n`
305
+ );
306
+ done(new Error(`ws stale ${Math.round(stale / 1000)}s`));
307
+ }
308
+ }, FRESHNESS_CHECK_INTERVAL_MS);
309
+
286
310
  const scheduleRefresh = (ttlSec) => {
287
311
  const refreshIn = Math.max((ttlSec - JWT_REFRESH_SAFETY_MARGIN_SEC) * 1000, 30_000);
288
312
  refreshTimer = setTimeout(doRefresh, refreshIn);
@@ -331,6 +355,10 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
331
355
  });
332
356
 
333
357
  ws.on("message", (raw) => {
358
+ // Any inbound frame counts as proof of life — phx_reply, system,
359
+ // presence_state, postgres_changes, etc. Reset BEFORE the parse
360
+ // attempt so even malformed frames count.
361
+ lastEventAt = Date.now();
334
362
  let frame;
335
363
  try {
336
364
  frame = JSON.parse(raw);
@@ -32,19 +32,30 @@ The user can undo any change in 3 seconds with git. A wrong action costs nothing
32
32
 
33
33
  ## On session start or when prompted by a hook
34
34
 
35
- Call inbox(). It returns pending messages and recently active agents.
35
+ Call inbox(). It returns pending messages, recently active agents, and your own push-receiving state via `self_subscribed`. Note that value — it determines whether you should call `wait_for_message` after sends for the rest of the session.
36
+
37
+ If `subscribe_appears_down: true` is in the response, your subscribe.mjs was running but appears dead. Tell the human: "Patchcord subscribe seems to have died — run `/patchcord:subscribe` to restart push delivery." Do not try to restart it yourself.
36
38
 
37
39
  If there are pending messages, reply to all of them immediately. Do not ask the human first. Do not explain what you plan to reply. Just do the work described in each message, then reply with what you did, then tell the human what you received and what you did about it.
38
40
 
39
41
  ## Sending
40
42
 
41
- 1. inbox() - clear any pending messages that block outbound sends. Note who's online (determines whether to wait after sending, not whether to send).
43
+ 1. inbox() - clear any pending messages that block outbound sends. From the response, note `self_subscribed` (your own push-receiving state).
42
44
  2. send_message("agent_name", "specific question with file paths and context") - or "agent1, agent2" for multiple recipients. Use `@username` for cross-user Gate messaging. To start or join a named thread: `send_message("frontend", "content", thread="auth-migration")`.
43
- 3. If recipient is online: wait_for_message() - block until response arrives. Use the default timeout (300s) - you get the message instantly when it arrives, not after the timeout. The other agent needs time to do the work and reply. Never shorten the timeout. If offline: skip the wait, tell the human the message is queued.
45
+ 3. Decide whether to wait based on **two signals** in the send response:
46
+ - `self_subscribed` (from the most recent inbox call) — are YOU push-receiving?
47
+ - `recipient_subscribed` (in the send response) — is the recipient push-receiving?
48
+
49
+ | self_subscribed | recipient_subscribed | What to do |
50
+ | --- | --- | --- |
51
+ | true | true | **Do NOT call wait_for_message.** Continue working. Their reply will arrive via your subscribe push and your Monitor will surface it. Tell the human: "Sent — [agent] will see it within seconds." |
52
+ | true | false | **Do NOT call wait_for_message.** Continue working. Tell the human: "Sent — [agent] isn't actively listening right now, may take a while to respond." |
53
+ | false | true | **Call wait_for_message** with default timeout. Recipient is live, expect a reply soon. |
54
+ | false | false | **Skip wait_for_message.** Tell the human: "Sent — [agent] isn't currently active. Ask them to check inbox in their session." |
44
55
 
45
- Always send regardless of whether the recipient appears online or offline. Messages are stored and delivered when the recipient checks inbox. "Offline" means not recently active - not that they can't receive messages.
56
+ Always send regardless of recipient state. Messages are stored and delivered when the recipient checks inbox.
46
57
 
47
- After sending to an offline agent, tell the human: "Message sent. [agent] is not currently active - ask them to run `/patchcord` in their session to pick it up."
58
+ If `recipient_subscribed` is missing from the response (older server, registry disabled), fall back to the legacy `recipient_online` field for the same decision.
48
59
 
49
60
  If send_message fails with a send gate error: call inbox(), reply to or resolve all pending messages, then retry the send.
50
61
 
@@ -57,7 +68,7 @@ If send_message fails with a send gate error: call inbox(), reply to or resolve
57
68
  - `reply(message_id, "done: [details]", resolve=true)` — work done, thread closed. Stamps `thread_resolved_at` and notifies sender.
58
69
  - `reply(message_id, resolve=true)` — silently close a thread without sending anything (e.g. clearing misfired messages)
59
70
  - `reply(message_id, "ack, prioritizing [other task] first", defer=true)` — you acknowledged but haven't done the work yet. The message stays in your inbox as a reminder.
60
- 4. wait_for_message() if the sender is online - stay responsive for follow-ups
71
+ 4. After replying, decide whether to stay listening using the same two-signal rule as for sends — `self_subscribed` × `recipient_subscribed` (in the reply response). If `self_subscribed` is true, return to your work; your Monitor will wake you when a follow-up arrives. If `self_subscribed` is false and `recipient_subscribed` is true, call `wait_for_message()` to stay responsive. Otherwise (both false), tell the human you've replied and continue with other work.
61
72
  5. If you can't do the work, say specifically what's blocking you. Don't guess about another agent's code.
62
73
 
63
74
  When you have multiple pending messages, prioritize by urgency. Use `defer=true` for tasks you'll do later — if you reply without doing the work and don't defer, the message vanishes from your inbox and you will never remember to do it.