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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.5.32",
3
+ "version": "0.5.34",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -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
- process.stderr.write(`subscribe: fatal: uncaught: ${err?.stack || err?.message || err}\n`);
37
+ logErr(`subscribe: fatal: uncaught: ${err?.stack || err?.message || err}`);
30
38
  process.exit(1);
31
39
  });
32
40
  process.on("unhandledRejection", (err) => {
33
- process.stderr.write(`subscribe: fatal: unhandled rejection: ${err?.stack || err?.message || err}\n`);
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
- process.stderr.write(msg + "\n");
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
- process.stderr.write(`subscribe: cwd=${cwd} server=${baseUrl}\n`);
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
- process.stderr.write(
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
- process.stderr.write(`subscribe: ${e.message}\n`);
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
- process.stderr.write(`subscribe: reconnecting in ${delay}ms\n`);
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
- process.stderr.write(`subscribe: ticket refresh failed: ${e.message}\n`);
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
- process.stderr.write("subscribe: connected\n");
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
- process.stderr.write(`subscribe: queue check failed: ${e.message}\n`);
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
- process.stderr.write(
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
- process.stderr.write("subscribe: token refreshed\n");
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
- process.stderr.write(`subscribe: token refresh failed, retrying in 30s: ${e.message}\n`);
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
- process.stderr.write(`subscribe: ws error: ${err.message}\n`);
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
- process.stderr.write(`subscribe: ws closed (${codeStr}${reasonStr})\n`);
401
+ logErr(`subscribe: ws closed (${codeStr}${reasonStr})`);
395
402
  done();
396
403
  });
397
404
  });
398
405
  }
399
406
 
400
407
  run().catch((e) => {
401
- process.stderr.write(`subscribe: fatal: ${e.message}\n`);
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, zero idle cost. Use when the user says "subscribe",
7
- "listen for patchcord messages", "wake me when messages arrive", or runs
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
- # What this does
12
-
13
- Spawns `scripts/subscribe.mjs` in the background. The script holds a
14
- WebSocket to Supabase Realtime and prints one line to stdout per new
15
- `agent_messages` INSERT for this agent. Claude Code's `Monitor` tool
16
- picks up each line as a notification; Claude wakes up and calls
17
- `inbox()`.
18
-
19
- No polling, no tokens burned while idle. The process stays alive until
20
- the user kills it or closes the Claude Code session.
21
-
22
- # How to find the script path (read carefully — this is the one thing that trips agents up)
23
-
24
- At the top of the skill invocation message, Claude Code shows a header:
25
- `Base directory for this skill: <ABSOLUTE_PATH>/skills/subscribe`
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>`. Do this:
29
+ Monitor surfaces `PATCHCORD: 1 new from <sender>`:
91
30
 
92
- 1. Say one brief line: "Got a Patchcord ping from <sender> — checking inbox."
31
+ 1. Say: *"Got a Patchcord ping from <sender> — checking inbox."*
93
32
  2. Call `mcp__patchcord__inbox`.
94
- 3. For each pending message, do the work first (follow the
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
- There is no `/patchcord:unsubscribe` command. Tell the user either:
101
-
102
- - Close this Claude Code session, OR
103
- - Run `kill $(cat /tmp/patchcord_subscribe_<namespace>_<agent>.pid)` in
104
- a terminal.
105
-
106
- # If the Monitor stream ends STRICT PROTOCOL
107
-
108
- The stream-end task notification includes the path to Monitor's output
109
- file. Do exactly this, in this order:
110
-
111
- 1. Read that output file using the Read tool.
112
- 2. Look at the last ~15 lines for a line matching one of the known
113
- failure strings below. There will always be at least one terminal
114
- error line — the script's error handlers guarantee it.
115
- 3. Report the specific cause to the user in one short sentence.
116
- 4. STOP.
117
-
118
- **Forbidden on failure — do not do any of these:**
119
- - Do NOT run `pgrep`, `ps`, `kill`, `pkill`, `killall`, or any command
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.