patchcord 0.5.32 → 0.5.34
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/bin/patchcord.mjs +21 -0
- package/package.json +1 -1
- package/scripts/subscribe.mjs +27 -20
- package/skills/subscribe/SKILL.md +41 -132
package/bin/patchcord.mjs
CHANGED
|
@@ -68,6 +68,7 @@ Usage:
|
|
|
68
68
|
npx patchcord@latest --token <token> --server <url> Self-hosted with custom server
|
|
69
69
|
npx patchcord@latest --full Same + full statusline
|
|
70
70
|
npx patchcord@latest --rename <new-name> [--tool <slug>] Rename this agent (paste from dashboard)
|
|
71
|
+
npx patchcord@latest subscribe Start the realtime listener (used by /patchcord:subscribe)
|
|
71
72
|
npx patchcord@latest skill apply Fetch custom skill from web console`);
|
|
72
73
|
process.exit(0);
|
|
73
74
|
}
|
|
@@ -77,6 +78,26 @@ if (cmd === "plugin-path") {
|
|
|
77
78
|
process.exit(0);
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// ── subscribe ─────────────────────────────────────────────────
|
|
82
|
+
// Thin wrapper around scripts/subscribe.mjs so users see a clean
|
|
83
|
+
// "npx patchcord subscribe" in their Claude Code tool log instead
|
|
84
|
+
// of a wall of bash. subscribe.mjs handles its own pidfile guard
|
|
85
|
+
// (exits with code 2 + "already running" if another listener is up),
|
|
86
|
+
// so this command needs zero pre-checks.
|
|
87
|
+
if (cmd === "subscribe") {
|
|
88
|
+
const subscribeScript = join(pluginRoot, "scripts", "subscribe.mjs");
|
|
89
|
+
if (!existsSync(subscribeScript)) {
|
|
90
|
+
console.error(`subscribe.mjs not found at ${subscribeScript}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
const { spawnSync } = await import("child_process");
|
|
94
|
+
const result = spawnSync(process.execPath, [subscribeScript], {
|
|
95
|
+
stdio: "inherit",
|
|
96
|
+
env: process.env,
|
|
97
|
+
});
|
|
98
|
+
process.exit(result.status ?? (result.signal ? 1 : 0));
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
// ── --rename <new> [--tool <slug>] [--expect-token <prefix>] ────
|
|
81
102
|
// Renames the agent's bearer in its tool's per-project config. Supported
|
|
82
103
|
// tools: claude_code, codex, cursor, vscode, opencode. Dashboard generates
|
package/package.json
CHANGED
package/scripts/subscribe.mjs
CHANGED
|
@@ -23,19 +23,27 @@ const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 15_000, 30_000];
|
|
|
23
23
|
const FRESHNESS_CHECK_INTERVAL_MS = 30_000;
|
|
24
24
|
const FRESHNESS_STALE_MS = 90_000;
|
|
25
25
|
|
|
26
|
+
// Short HH:MM:SS prefix so the Monitor output can be scanned at a glance.
|
|
27
|
+
// Local time — Monitor's reader is always a human looking at one machine.
|
|
28
|
+
function logErr(msg) {
|
|
29
|
+
const d = new Date();
|
|
30
|
+
const ts = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
|
|
31
|
+
process.stderr.write(`[${ts}] ${msg}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
// Guarantee a terminal stderr line on any unhandled failure so the agent
|
|
27
35
|
// reading Monitor's output file always sees WHY the process died.
|
|
28
36
|
process.on("uncaughtException", (err) => {
|
|
29
|
-
|
|
37
|
+
logErr(`subscribe: fatal: uncaught: ${err?.stack || err?.message || err}`);
|
|
30
38
|
process.exit(1);
|
|
31
39
|
});
|
|
32
40
|
process.on("unhandledRejection", (err) => {
|
|
33
|
-
|
|
41
|
+
logErr(`subscribe: fatal: unhandled rejection: ${err?.stack || err?.message || err}`);
|
|
34
42
|
process.exit(1);
|
|
35
43
|
});
|
|
36
44
|
|
|
37
45
|
function die(msg, code = 1) {
|
|
38
|
-
|
|
46
|
+
logErr(msg);
|
|
39
47
|
process.exit(code);
|
|
40
48
|
}
|
|
41
49
|
|
|
@@ -178,7 +186,7 @@ function removePidfile(path) {
|
|
|
178
186
|
async function run() {
|
|
179
187
|
const cwd = process.cwd();
|
|
180
188
|
const { baseUrl, token } = readMcpConfig(cwd);
|
|
181
|
-
|
|
189
|
+
logErr(`subscribe: cwd=${cwd} server=${baseUrl}`);
|
|
182
190
|
|
|
183
191
|
let ticket = await fetchTicket(baseUrl, token);
|
|
184
192
|
const pidfile = `/tmp/patchcord_subscribe_${ticket.namespace_ids[0]}_${ticket.agent_id}.pid`;
|
|
@@ -195,9 +203,7 @@ async function run() {
|
|
|
195
203
|
process.exit(0);
|
|
196
204
|
});
|
|
197
205
|
|
|
198
|
-
|
|
199
|
-
`subscribe: agent=${ticket.agent_id} namespaces=${ticket.namespace_ids.join(",")}\n`
|
|
200
|
-
);
|
|
206
|
+
logErr(`subscribe: agent=${ticket.agent_id} namespaces=${ticket.namespace_ids.join(",")}`);
|
|
201
207
|
|
|
202
208
|
let backoffIdx = 0;
|
|
203
209
|
|
|
@@ -210,16 +216,16 @@ async function run() {
|
|
|
210
216
|
});
|
|
211
217
|
backoffIdx = 0; // clean disconnect resets backoff
|
|
212
218
|
} catch (e) {
|
|
213
|
-
|
|
219
|
+
logErr(`subscribe: ${e.message}`);
|
|
214
220
|
}
|
|
215
221
|
const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
|
|
216
222
|
backoffIdx++;
|
|
217
|
-
|
|
223
|
+
logErr(`subscribe: reconnecting in ${delay}ms`);
|
|
218
224
|
await new Promise((r) => setTimeout(r, delay));
|
|
219
225
|
try {
|
|
220
226
|
ticket = await fetchTicket(baseUrl, token);
|
|
221
227
|
} catch (e) {
|
|
222
|
-
|
|
228
|
+
logErr(`subscribe: ticket refresh failed: ${e.message}`);
|
|
223
229
|
}
|
|
224
230
|
}
|
|
225
231
|
};
|
|
@@ -255,7 +261,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
255
261
|
};
|
|
256
262
|
|
|
257
263
|
ws.on("open", () => {
|
|
258
|
-
|
|
264
|
+
logErr("subscribe: connected");
|
|
259
265
|
for (const topic of ticket.topics) {
|
|
260
266
|
ws.send(
|
|
261
267
|
JSON.stringify({
|
|
@@ -277,7 +283,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
277
283
|
// failure here just means we miss queued messages this round;
|
|
278
284
|
// the next reconnect retries.
|
|
279
285
|
drainQueueOnce(baseUrl, token).catch((e) => {
|
|
280
|
-
|
|
286
|
+
logErr(`subscribe: queue check failed: ${e.message}`);
|
|
281
287
|
});
|
|
282
288
|
heartbeatTimer = setInterval(() => {
|
|
283
289
|
try {
|
|
@@ -300,9 +306,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
300
306
|
freshnessTimer = setInterval(() => {
|
|
301
307
|
const stale = Date.now() - lastEventAt;
|
|
302
308
|
if (stale > FRESHNESS_STALE_MS) {
|
|
303
|
-
|
|
304
|
-
`subscribe: ws stale ${Math.round(stale / 1000)}s, forcing reconnect\n`
|
|
305
|
-
);
|
|
309
|
+
logErr(`subscribe: ws stale ${Math.round(stale / 1000)}s, forcing reconnect`);
|
|
306
310
|
done(new Error(`ws stale ${Math.round(stale / 1000)}s`));
|
|
307
311
|
}
|
|
308
312
|
}, FRESHNESS_CHECK_INTERVAL_MS);
|
|
@@ -340,13 +344,16 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
340
344
|
})
|
|
341
345
|
);
|
|
342
346
|
}
|
|
343
|
-
|
|
347
|
+
// Routine refresh success — don't log. Over days this fills the
|
|
348
|
+
// Monitor output with ~250 identical lines and buries the rare
|
|
349
|
+
// interesting events (connects, errors). Refresh failures DO log,
|
|
350
|
+
// because those are the ones worth scanning for.
|
|
344
351
|
scheduleRefresh(fresh.jwt_expires_in);
|
|
345
352
|
} catch (e) {
|
|
346
353
|
// Transient network/server error — do NOT close the live
|
|
347
354
|
// connection. The current JWT is still valid for ~2 more min
|
|
348
355
|
// (JWT_REFRESH_SAFETY_MARGIN_SEC). Retry sooner.
|
|
349
|
-
|
|
356
|
+
logErr(`subscribe: token refresh failed, retrying in 30s: ${e.message}`);
|
|
350
357
|
refreshTimer = setTimeout(doRefresh, 30_000);
|
|
351
358
|
}
|
|
352
359
|
};
|
|
@@ -384,20 +391,20 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
|
|
|
384
391
|
});
|
|
385
392
|
|
|
386
393
|
ws.on("error", (err) => {
|
|
387
|
-
|
|
394
|
+
logErr(`subscribe: ws error: ${err.message}`);
|
|
388
395
|
done(err);
|
|
389
396
|
});
|
|
390
397
|
|
|
391
398
|
ws.on("close", (info) => {
|
|
392
399
|
const codeStr = info?.code != null ? `code=${info.code}` : "code=none";
|
|
393
400
|
const reasonStr = info?.reason ? ` reason=${JSON.stringify(info.reason)}` : "";
|
|
394
|
-
|
|
401
|
+
logErr(`subscribe: ws closed (${codeStr}${reasonStr})`);
|
|
395
402
|
done();
|
|
396
403
|
});
|
|
397
404
|
});
|
|
398
405
|
}
|
|
399
406
|
|
|
400
407
|
run().catch((e) => {
|
|
401
|
-
|
|
408
|
+
logErr(`subscribe: fatal: ${e.message}`);
|
|
402
409
|
process.exit(1);
|
|
403
410
|
});
|
|
@@ -3,145 +3,54 @@ name: patchcord:subscribe
|
|
|
3
3
|
description: >
|
|
4
4
|
Start a background listener that wakes Claude the moment a new Patchcord
|
|
5
5
|
message arrives for this agent. Uses Supabase Realtime over WebSocket —
|
|
6
|
-
zero polling
|
|
7
|
-
|
|
8
|
-
/patchcord:subscribe.
|
|
6
|
+
zero polling. Use when the user says "subscribe", "listen for patchcord
|
|
7
|
+
messages", "wake me when messages arrive", or runs /patchcord:subscribe.
|
|
9
8
|
---
|
|
10
9
|
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
Take that path, strip `/skills/subscribe` from the end — you now have the
|
|
28
|
-
plugin root. The script is at `<PLUGIN_ROOT>/scripts/subscribe.mjs`.
|
|
29
|
-
|
|
30
|
-
**Do not rely on `$CLAUDE_PLUGIN_ROOT`** — it is often unset inside
|
|
31
|
-
the Bash shell even when the skill is running. Always derive the path
|
|
32
|
-
from the "Base directory for this skill" header you were given.
|
|
33
|
-
|
|
34
|
-
Example: if the header says
|
|
35
|
-
`Base directory for this skill: /home/user/.npm/_npx/abc123/node_modules/patchcord/skills/subscribe`
|
|
36
|
-
then the script is at
|
|
37
|
-
`/home/user/.npm/_npx/abc123/node_modules/patchcord/scripts/subscribe.mjs`.
|
|
38
|
-
|
|
39
|
-
# Starting (step by step)
|
|
40
|
-
|
|
41
|
-
1. **Know your identity.** If you don't already have `namespace_id` and
|
|
42
|
-
`agent_id` from this session, call `mcp__patchcord__inbox` once — the
|
|
43
|
-
response starts with `<agent>@<namespace> | N pending` and you can
|
|
44
|
-
read both off that line.
|
|
45
|
-
|
|
46
|
-
2. **Compute the pidfile path:**
|
|
47
|
-
`/tmp/patchcord_subscribe_<namespace_id>_<agent_id>.pid`
|
|
48
|
-
|
|
49
|
-
3. **Check for an existing listener.** One Bash call:
|
|
50
|
-
```bash
|
|
51
|
-
PF=/tmp/patchcord_subscribe_<ns>_<agent>.pid
|
|
52
|
-
if [ -f "$PF" ] && kill -0 "$(cat "$PF")" 2>/dev/null; then
|
|
53
|
-
echo "ALREADY_RUNNING pid=$(cat "$PF")"
|
|
54
|
-
else
|
|
55
|
-
echo "OK_TO_SPAWN"
|
|
56
|
-
fi
|
|
57
|
-
```
|
|
58
|
-
If output is `ALREADY_RUNNING`, tell the user "Patchcord listener
|
|
59
|
-
already active (pid N)" and STOP. Do not spawn another one.
|
|
60
|
-
|
|
61
|
-
4. **Resolve the script path** using the recipe above.
|
|
62
|
-
|
|
63
|
-
5. **Spawn under Monitor** — not Bash with `run_in_background`. Monitor
|
|
64
|
-
is the right tool because every stdout line becomes a notification.
|
|
65
|
-
Example call shape:
|
|
66
|
-
```
|
|
67
|
-
Monitor(
|
|
68
|
-
description: "patchcord realtime listener (<agent>@<ns>)",
|
|
69
|
-
persistent: true,
|
|
70
|
-
timeout_ms: 3600000,
|
|
71
|
-
command: "exec node \"<absolute-path-to-subscribe.mjs>\""
|
|
72
|
-
)
|
|
73
|
-
```
|
|
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
|
-
|
|
81
|
-
Crash detection is handled automatically by Monitor itself: when the
|
|
82
|
-
process exits, Monitor emits a built-in "stream ended" task
|
|
83
|
-
notification with the output file path and exit code.
|
|
84
|
-
|
|
85
|
-
6. **Tell the user one short line:**
|
|
86
|
-
"Patchcord listener active — I'll pick up new messages as they arrive."
|
|
10
|
+
# Start
|
|
11
|
+
|
|
12
|
+
Spawn under Monitor (not Bash with run_in_background — Monitor turns each stdout line into a notification):
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
Monitor(
|
|
16
|
+
description: "patchcord realtime listener",
|
|
17
|
+
persistent: true,
|
|
18
|
+
timeout_ms: 3600000,
|
|
19
|
+
command: "exec npx patchcord subscribe"
|
|
20
|
+
)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`subscribe.mjs` handles its own pidfile guard — if another listener is already active for this agent it exits with code 2 and stderr `already running (pid N)`. Monitor catches the stream-end event; read the output file and report.
|
|
24
|
+
|
|
25
|
+
Then one line to the user: *"Patchcord listener active — I'll pick up new messages as they arrive."*
|
|
87
26
|
|
|
88
27
|
# When a notification fires
|
|
89
28
|
|
|
90
|
-
Monitor surfaces `PATCHCORD: 1 new from <sender
|
|
29
|
+
Monitor surfaces `PATCHCORD: 1 new from <sender>`:
|
|
91
30
|
|
|
92
|
-
1. Say
|
|
31
|
+
1. Say: *"Got a Patchcord ping from <sender> — checking inbox."*
|
|
93
32
|
2. Call `mcp__patchcord__inbox`.
|
|
94
|
-
3.
|
|
95
|
-
patchcord:inbox skill), then reply with what you did.
|
|
96
|
-
4. Return to listening — Monitor keeps running.
|
|
33
|
+
3. Do the work per the patchcord:inbox skill, reply with what you did.
|
|
97
34
|
|
|
98
35
|
# Stopping
|
|
99
36
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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:
|
|
130
|
-
|
|
131
|
-
- `no .mcp.json in <cwd>` — session is not in a patchcord project dir.
|
|
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.
|
|
37
|
+
Tell the user one of:
|
|
38
|
+
- Close this Claude Code session.
|
|
39
|
+
- `kill $(cat /tmp/patchcord_subscribe_<namespace>_<agent>.pid)`
|
|
40
|
+
|
|
41
|
+
# If the Monitor stream ends
|
|
42
|
+
|
|
43
|
+
Read the output file. Scan the last ~15 lines for one of:
|
|
44
|
+
|
|
45
|
+
- `no .mcp.json in <cwd>` — session is not in a patchcord project dir
|
|
46
|
+
- `ticket: token rejected (HTTP 401|403)` — bad bearer; user regenerates from dashboard
|
|
47
|
+
- `ticket: server not configured for realtime` — self-hosted without Supabase
|
|
48
|
+
- `ticket: namespace not owned` — token lost its owner; regenerate
|
|
49
|
+
- `already running (pid N)` (exit 2) — another listener is active; report and stop
|
|
50
|
+
- `subscribe: fatal: ...` — surface the line verbatim
|
|
51
|
+
|
|
52
|
+
Report the cause in one sentence. STOP.
|
|
53
|
+
|
|
54
|
+
**Forbidden on failure:** no `pgrep`/`ps`/`kill`/`pkill`/`killall`, no pidfile writes, no respawning. The script manages pidfile cleanup itself; respawning will not fix a config problem.
|
|
55
|
+
|
|
56
|
+
Clean exit (code 0) with no error line = the user closed the session or killed the process. Nothing to do.
|