patchcord 0.5.13 → 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/.claude-plugin/plugin.json +1 -1
- package/bin/patchcord.mjs +14 -3
- package/package.json +1 -1
- package/scripts/subscribe.mjs +28 -0
- package/skills/inbox/SKILL.md +17 -6
package/bin/patchcord.mjs
CHANGED
|
@@ -366,12 +366,23 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
|
|
|
366
366
|
}
|
|
367
367
|
rl.close();
|
|
368
368
|
} else {
|
|
369
|
-
// Check if patchcord is already configured — offer to update URL without re-auth
|
|
369
|
+
// Check if patchcord is already configured — offer to update URL without re-auth.
|
|
370
|
+
// When --tool=<slug> is set, we ONLY look at the config file that <slug>
|
|
371
|
+
// would itself write to. Other tools' existing configs in the same project
|
|
372
|
+
// are not a "we already have patchcord here" signal — the user is explicit
|
|
373
|
+
// about which tool they're setting up, the question "Add another agent?"
|
|
374
|
+
// makes no sense across tool boundaries (claude_code already configured
|
|
375
|
+
// doesn't change anything about installing codex).
|
|
370
376
|
let existingToken = "";
|
|
371
377
|
let existingConfigFile = "";
|
|
372
378
|
const mcpJsonPath = join(cwd, ".mcp.json");
|
|
373
379
|
const codexTomlPath = join(cwd, ".codex", "config.toml");
|
|
374
|
-
|
|
380
|
+
|
|
381
|
+
const slugForCheck = toolSlug ? toolSlug.replace(/-/g, "_") : "";
|
|
382
|
+
const checkMcpJson = !slugForCheck || slugForCheck === "claude_code";
|
|
383
|
+
const checkCodexToml = !slugForCheck || slugForCheck === "codex";
|
|
384
|
+
|
|
385
|
+
if (checkMcpJson && existsSync(mcpJsonPath)) {
|
|
375
386
|
try {
|
|
376
387
|
const existing = JSON.parse(readFileSync(mcpJsonPath, "utf-8"));
|
|
377
388
|
const pt = existing?.mcpServers?.patchcord;
|
|
@@ -381,7 +392,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
|
|
|
381
392
|
}
|
|
382
393
|
} catch {}
|
|
383
394
|
}
|
|
384
|
-
if (!existingToken && existsSync(codexTomlPath)) {
|
|
395
|
+
if (!existingToken && checkCodexToml && existsSync(codexTomlPath)) {
|
|
385
396
|
try {
|
|
386
397
|
const content = readFileSync(codexTomlPath, "utf-8");
|
|
387
398
|
const match = content.match(/Bearer\s+([^\s"]+)/);
|
package/package.json
CHANGED
package/scripts/subscribe.mjs
CHANGED
|
@@ -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);
|
package/skills/inbox/SKILL.md
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
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
|
|
56
|
+
Always send regardless of recipient state. Messages are stored and delivered when the recipient checks inbox.
|
|
46
57
|
|
|
47
|
-
|
|
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.
|
|
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.
|