patchcord 0.6.45 → 0.6.47
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/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/subscribe/SKILL.md +43 -23
- package/package.json +1 -1
- package/per-project-skills/jcode/subscribe/SKILL.md +25 -1
- package/scripts/lib/pidfile.mjs +63 -0
- package/scripts/subscribe.mjs +89 -2
- package/skills/subscribe/SKILL.md +43 -23
package/agent-plugin/plugin.json
CHANGED
|
@@ -44,33 +44,53 @@ WAKES YOU: the pipe fills with messages you are never told about, and you
|
|
|
44
44
|
discover them only when the user asks why you did not answer. That has happened
|
|
45
45
|
in production, on jcode, and is the reason this section exists.
|
|
46
46
|
|
|
47
|
-
So, if you have no `Monitor
|
|
47
|
+
So, if you have no `Monitor`, **use `--stall-signal`. It was built for exactly
|
|
48
|
+
this and it is not optional here:**
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
```
|
|
51
|
+
patchcord subscribe --stall-signal | grep --line-buffered '^PATCHCORD:'; exit ${PIPESTATUS[0]}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
run as a background command with your harness's stall wake set to **15
|
|
55
|
+
seconds** (jcode: `stall_wake_seconds: 15`).
|
|
56
|
+
|
|
57
|
+
1. **What `--stall-signal` does.** While idle it writes a `HEARTBEAT:` line
|
|
58
|
+
every 5 s, so the pipe never looks silent and the stall never fires on
|
|
59
|
+
nothing. When a real message arrives it writes the `PATCHCORD:` line and
|
|
60
|
+
then goes QUIET ON PURPOSE for 15 s, so your harness's stall detector fires
|
|
61
|
+
because a message came in. It turns wake-on-silence into wake-on-message
|
|
62
|
+
using only the primitive your harness has.
|
|
63
|
+
|
|
64
|
+
The `grep` drops the `HEARTBEAT:` lines from what you see; they still keep
|
|
65
|
+
the pipe alive, which is their whole job.
|
|
53
66
|
|
|
54
|
-
2. **
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
2. **The two numbers must agree.** Your stall wake MUST equal `--stall-signal`'s
|
|
68
|
+
quiet window. Both default to 15 s, so setting `15` and passing no inline
|
|
69
|
+
value is correct. If you pass a custom triple
|
|
70
|
+
(`--stall-signal keepaliveMs:quietMs:stallMs`), your stall wake must equal
|
|
71
|
+
the `stallMs` you passed. Too short and it fires on nothing; too long and it
|
|
72
|
+
never fires on a real message. **Do not use 30** — that number predates this
|
|
73
|
+
mode and is simply wrong for it, not safer.
|
|
59
74
|
|
|
60
75
|
3. **NEVER read the last `PATCHCORD:` line in the task output as news.** It is
|
|
61
|
-
scrollback
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
scrollback and it may be a line you already handled. Two lines look almost
|
|
77
|
+
alike: a live arrival, and a `... waiting in inbox (snapshot at HH:MM:SS)`
|
|
78
|
+
written once when the listener connected and drained the queue. The
|
|
79
|
+
timestamp on the second is there so you can see it is old — check it. In a
|
|
80
|
+
real jcode session an agent re-announced that same drain line repeatedly and
|
|
81
|
+
missed five actual messages while doing so. On every wake call
|
|
82
|
+
`mcp__patchcord__inbox` and believe only that. If it is empty, say nothing
|
|
83
|
+
and go back to waiting.
|
|
84
|
+
|
|
85
|
+
4. **If the inbox is empty, check the listener is still alive** before assuming
|
|
86
|
+
a harmless false wake. A dead listener produces the same silence. If a
|
|
87
|
+
restart is refused with `already running (pid N)`, the process outlived the
|
|
88
|
+
task that tracked it: restart with `--replace`, which is the only sanctioned
|
|
89
|
+
way to remove a running listener. Never `kill`/`pkill` by hand.
|
|
90
|
+
|
|
91
|
+
**Only if your harness has no stall setting at all** does this degrade to a
|
|
92
|
+
timed poll. Say so plainly to the user in that case — tell them you will check
|
|
93
|
+
regularly, never that you will be woken "as messages arrive".
|
|
74
94
|
|
|
75
95
|
# When a notification fires
|
|
76
96
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "patchcord",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.47",
|
|
4
4
|
"description": "Cross-machine agent messaging for Claude Code and Codex",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin/plugin.json agent-plugin/plugin.json"
|
|
@@ -72,6 +72,29 @@ cannot tell which one happened from the wake alone:
|
|
|
72
72
|
produces silence — that is a feature (it is how you notice), but only if
|
|
73
73
|
you actually look. If the background task has exited, restart it with the
|
|
74
74
|
command in Start, step 2.
|
|
75
|
+
|
|
76
|
+
**If the restart is refused with `already running (pid N)`, add
|
|
77
|
+
`--replace`.** That message means a listener process is alive while your
|
|
78
|
+
background task is not tracking it — which is the worst state available
|
|
79
|
+
here, because it receives messages and can no longer wake you, and it
|
|
80
|
+
holds the pidfile that refuses the correct respawn. It happens when a
|
|
81
|
+
cancel is rejected as stale and the process outlives the task that owned
|
|
82
|
+
it.
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
Bash(
|
|
86
|
+
command: "patchcord subscribe --replace --stall-signal | grep --line-buffered '^PATCHCORD:'; exit ${PIPESTATUS[0]}",
|
|
87
|
+
run_in_background: true,
|
|
88
|
+
stall_wake_seconds: 15
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`--replace` terminates the pidfile's holder and takes over. It signals only
|
|
93
|
+
a pid read from patchcord's own pidfile, and only after confirming that pid
|
|
94
|
+
is a patchcord listener — a reused pid belonging to something else is left
|
|
95
|
+
alone and the file is treated as stale. This is the ONLY sanctioned way to
|
|
96
|
+
remove a running listener. Do not reach for `kill`, `pkill`, or the pidfile
|
|
97
|
+
yourself.
|
|
75
98
|
3. **Never read the last `PATCHCORD:` line in the task output as news.** It
|
|
76
99
|
is scrollback — output already displayed. It may be the same line you
|
|
77
100
|
already handled. The inbox call in step 1 is the source of truth; a
|
|
@@ -96,7 +119,8 @@ Read its output. Scan for one of:
|
|
|
96
119
|
- `ticket: token rejected (HTTP 401|403)` — the bearer token is invalid or
|
|
97
120
|
expired.
|
|
98
121
|
- `already running (pid N)` (exit 2) — another listener is active for this
|
|
99
|
-
agent
|
|
122
|
+
agent. Do not respawn it unchanged, and do not kill it by hand: restart with
|
|
123
|
+
`--replace` as described above.
|
|
100
124
|
- `subscribe: fatal: ...` — report the fatal line verbatim.
|
|
101
125
|
|
|
102
126
|
If none of those appear, it likely ended with the session or because its
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Pidfile takeover for `patchcord subscribe --replace`.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
//
|
|
5
|
+
// A running listener is not the problem; being UNABLE TO REPLACE IT is. On a
|
|
6
|
+
// harness whose background-task tracking can be lost - jcode, where a restart
|
|
7
|
+
// whose cancel is rejected as stale leaves the process alive but no longer
|
|
8
|
+
// attached to a stall wake - the listener keeps receiving and can no longer
|
|
9
|
+
// wake anybody. Because it still holds the pidfile, the correct respawn is
|
|
10
|
+
// then refused with `already running`. The agent is stuck between a guard
|
|
11
|
+
// doing its job and a skill that forbids reaching for kill.
|
|
12
|
+
//
|
|
13
|
+
// So the recovery is a flag rather than instructions a model may not follow.
|
|
14
|
+
//
|
|
15
|
+
// THE SAFETY BOUNDARY, which is the whole reason this is a separate module
|
|
16
|
+
// with its own tests: this only ever signals a pid READ FROM OUR OWN PIDFILE
|
|
17
|
+
// PATH, and only after confirming that pid is one of ours. PIDs are reused. A
|
|
18
|
+
// file left behind by a crashed listener can name a process that now belongs
|
|
19
|
+
// to somebody else, and killing it because a stale file pointed there would
|
|
20
|
+
// be far worse than the stuck state this fixes. When identity cannot be
|
|
21
|
+
// established the file is treated as stale and taken over WITHOUT signalling
|
|
22
|
+
// anything.
|
|
23
|
+
//
|
|
24
|
+
// Kept out of subscribe.mjs so the decision can be unit tested without
|
|
25
|
+
// opening a WebSocket, resolving a project config, or touching a real
|
|
26
|
+
// pidfile - all things subscribe.mjs does as soon as it runs.
|
|
27
|
+
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
|
+
|
|
30
|
+
/** Is `pid` one of our own subscribe processes? Reads the kernel's own record
|
|
31
|
+
* of the command line rather than trusting the pidfile's existence.
|
|
32
|
+
*
|
|
33
|
+
* Returns false whenever that cannot be established - a process we cannot
|
|
34
|
+
* read, a platform without /proc, a command line that does not look like
|
|
35
|
+
* ours. False means "do not signal it", so every uncertain case is safe by
|
|
36
|
+
* construction rather than by the caller remembering to check. */
|
|
37
|
+
export function isOurSubscribeProcess(pid, deps = {}) {
|
|
38
|
+
const read = deps.readFile || ((p) => readFileSync(p, "utf8"));
|
|
39
|
+
try {
|
|
40
|
+
const cmdline = String(read(`/proc/${pid}/cmdline`)).replace(/\0/g, " ");
|
|
41
|
+
return /subscribe\.mjs/.test(cmdline) || /patchcord[^ ]* subscribe/.test(cmdline);
|
|
42
|
+
} catch (_) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Decide what --replace should do about an existing pidfile holder.
|
|
48
|
+
*
|
|
49
|
+
* Pure: takes facts, returns one of three verdicts, signals nothing itself.
|
|
50
|
+
* The caller performs the action. This split is what lets the dangerous
|
|
51
|
+
* case - "live pid that is not ours" - be tested without a real kill.
|
|
52
|
+
*
|
|
53
|
+
* "start" nothing is holding it; just start
|
|
54
|
+
* "takeover" the file is stale or names a foreign process; take the
|
|
55
|
+
* file, signal NOTHING
|
|
56
|
+
* "terminate" a live listener of ours; terminate it, then take the file
|
|
57
|
+
*/
|
|
58
|
+
export function decidePidfileAction({ existingPid, selfPid, isAlive, isOurs }) {
|
|
59
|
+
if (!existingPid || existingPid === selfPid) return "start";
|
|
60
|
+
if (!isAlive) return "takeover";
|
|
61
|
+
if (!isOurs) return "takeover";
|
|
62
|
+
return "terminate";
|
|
63
|
+
}
|
package/scripts/subscribe.mjs
CHANGED
|
@@ -12,9 +12,11 @@ import { request as httpsRequest } from "node:https";
|
|
|
12
12
|
import { request as httpRequest } from "node:http";
|
|
13
13
|
import { URL } from "node:url";
|
|
14
14
|
import { dirname } from "node:path";
|
|
15
|
+
import { execSync } from "node:child_process";
|
|
15
16
|
import { connect as wsConnect } from "./lib/ws.mjs";
|
|
16
17
|
import { resolveProjectBearer, listProjectBearers } from "./lib/resolve-project-bearer.mjs";
|
|
17
18
|
import { parseStallSignalArg } from "./lib/stall-signal.mjs";
|
|
19
|
+
import { isOurSubscribeProcess, decidePidfileAction } from "./lib/pidfile.mjs";
|
|
18
20
|
|
|
19
21
|
// --- Hermes webhook bridge mode -------------------------------------------
|
|
20
22
|
// Default mode writes "PATCHCORD: ..." lines to stdout for Claude Code's
|
|
@@ -293,7 +295,20 @@ async function drainQueueOnce(baseUrl, token) {
|
|
|
293
295
|
count = JSON.parse(res.body).pending_count ?? 0;
|
|
294
296
|
} catch (_) {}
|
|
295
297
|
if (count > 0) {
|
|
296
|
-
|
|
298
|
+
// SELF-DATING, DELIBERATELY. This line and a live-arrival line both
|
|
299
|
+
// start with "PATCHCORD:" and both stay in the task output forever.
|
|
300
|
+
// On a harness that wakes on a stall, the agent re-reads the tail of
|
|
301
|
+
// that output on every wake, and an undated snapshot from twenty
|
|
302
|
+
// minutes ago is indistinguishable from a message that just landed -
|
|
303
|
+
// reported in a jcode session where five real messages were missed
|
|
304
|
+
// while the same old line was announced repeatedly as news.
|
|
305
|
+
// The clock is what makes a re-read visibly stale.
|
|
306
|
+
const at = new Date().toTimeString().slice(0, 8);
|
|
307
|
+
await notify(`PATCHCORD: ${count} waiting in inbox (snapshot at ${at})`, {
|
|
308
|
+
count,
|
|
309
|
+
kind: "pending",
|
|
310
|
+
at,
|
|
311
|
+
});
|
|
297
312
|
}
|
|
298
313
|
return;
|
|
299
314
|
} catch (e) {
|
|
@@ -310,6 +325,56 @@ async function drainQueueOnce(baseUrl, token) {
|
|
|
310
325
|
throw lastErr;
|
|
311
326
|
}
|
|
312
327
|
|
|
328
|
+
// --replace: the running listener is not the problem, being UNABLE TO REPLACE
|
|
329
|
+
// IT is. On a harness whose background-task tracking can be lost (jcode: a
|
|
330
|
+
// restart whose cancel was rejected as stale leaves the process alive but no
|
|
331
|
+
// longer attached to a stall wake), the listener keeps receiving and can no
|
|
332
|
+
// longer wake anybody — and because it still holds the pidfile, the correct
|
|
333
|
+
// respawn is refused with `already running`. The agent is then stuck between
|
|
334
|
+
// a guard doing its job and a skill that forbids reaching for kill.
|
|
335
|
+
//
|
|
336
|
+
// So the recovery is a flag rather than instructions: `patchcord subscribe
|
|
337
|
+
// --replace` terminates the pidfile's holder and takes over.
|
|
338
|
+
//
|
|
339
|
+
// IT ONLY EVER SIGNALS A PID READ FROM OUR OWN PIDFILE PATH, and only after
|
|
340
|
+
// confirming that pid is one of ours. PIDs are reused: a file left by a
|
|
341
|
+
// crashed listener can name a process that now belongs to somebody else, and
|
|
342
|
+
// killing it because a stale file pointed there would be far worse than the
|
|
343
|
+
// stuck state this fixes. When the identity cannot be confirmed the file is
|
|
344
|
+
// treated as stale and taken over WITHOUT signalling anything.
|
|
345
|
+
const REPLACE_MODE = process.argv.includes("--replace");
|
|
346
|
+
|
|
347
|
+
/** SIGTERM, then SIGKILL if it is still there. Returns true once the pid is
|
|
348
|
+
* gone. Bounded: it must not hang a listener start forever. */
|
|
349
|
+
function terminateAndWait(pid, deadlineMs = 3000) {
|
|
350
|
+
const isGone = () => {
|
|
351
|
+
try {
|
|
352
|
+
process.kill(pid, 0);
|
|
353
|
+
return false;
|
|
354
|
+
} catch (_) {
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
try {
|
|
359
|
+
process.kill(pid, "SIGTERM");
|
|
360
|
+
} catch (_) {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
const started = Date.now();
|
|
364
|
+
while (Date.now() - started < deadlineMs) {
|
|
365
|
+
if (isGone()) return true;
|
|
366
|
+
try {
|
|
367
|
+
// Busy-wait deliberately: this runs once, before the socket opens, and
|
|
368
|
+
// an async sleep here would let the rest of startup race the takeover.
|
|
369
|
+
execSync("sleep 0.1");
|
|
370
|
+
} catch (_) {}
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
process.kill(pid, "SIGKILL");
|
|
374
|
+
} catch (_) {}
|
|
375
|
+
return isGone();
|
|
376
|
+
}
|
|
377
|
+
|
|
313
378
|
function writePidfile(path) {
|
|
314
379
|
try {
|
|
315
380
|
writeFileSync(path, String(process.pid), { flag: "wx" });
|
|
@@ -321,7 +386,29 @@ function writePidfile(path) {
|
|
|
321
386
|
if (existingPid && existingPid !== process.pid) {
|
|
322
387
|
try {
|
|
323
388
|
process.kill(existingPid, 0);
|
|
324
|
-
|
|
389
|
+
if (REPLACE_MODE) {
|
|
390
|
+
const action = decidePidfileAction({
|
|
391
|
+
existingPid,
|
|
392
|
+
selfPid: process.pid,
|
|
393
|
+
isAlive: true,
|
|
394
|
+
isOurs: isOurSubscribeProcess(existingPid),
|
|
395
|
+
});
|
|
396
|
+
if (action === "takeover") {
|
|
397
|
+
// A live pid that is NOT ours means the file is stale and the
|
|
398
|
+
// number was reused. Take the file; signal nothing.
|
|
399
|
+
logErr(`subscribe: --replace: pid ${existingPid} is not a patchcord listener — treating the pidfile as stale`);
|
|
400
|
+
} else if (terminateAndWait(existingPid)) {
|
|
401
|
+
logErr(`subscribe: --replace: terminated the previous listener (pid ${existingPid})`);
|
|
402
|
+
} else {
|
|
403
|
+
die(`--replace: could not terminate the previous listener (pid ${existingPid})`, 2);
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
unlinkSync(path);
|
|
407
|
+
} catch (_) {}
|
|
408
|
+
writeFileSync(path, String(process.pid), { flag: "wx" });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
die(`already running (pid ${existingPid}) — use \`patchcord subscribe --replace\` to take over`, 2);
|
|
325
412
|
} catch (_) {
|
|
326
413
|
// stale
|
|
327
414
|
try {
|
|
@@ -44,33 +44,53 @@ WAKES YOU: the pipe fills with messages you are never told about, and you
|
|
|
44
44
|
discover them only when the user asks why you did not answer. That has happened
|
|
45
45
|
in production, on jcode, and is the reason this section exists.
|
|
46
46
|
|
|
47
|
-
So, if you have no `Monitor
|
|
47
|
+
So, if you have no `Monitor`, **use `--stall-signal`. It was built for exactly
|
|
48
|
+
this and it is not optional here:**
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
```
|
|
51
|
+
patchcord subscribe --stall-signal | grep --line-buffered '^PATCHCORD:'; exit ${PIPESTATUS[0]}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
run as a background command with your harness's stall wake set to **15
|
|
55
|
+
seconds** (jcode: `stall_wake_seconds: 15`).
|
|
56
|
+
|
|
57
|
+
1. **What `--stall-signal` does.** While idle it writes a `HEARTBEAT:` line
|
|
58
|
+
every 5 s, so the pipe never looks silent and the stall never fires on
|
|
59
|
+
nothing. When a real message arrives it writes the `PATCHCORD:` line and
|
|
60
|
+
then goes QUIET ON PURPOSE for 15 s, so your harness's stall detector fires
|
|
61
|
+
because a message came in. It turns wake-on-silence into wake-on-message
|
|
62
|
+
using only the primitive your harness has.
|
|
63
|
+
|
|
64
|
+
The `grep` drops the `HEARTBEAT:` lines from what you see; they still keep
|
|
65
|
+
the pipe alive, which is their whole job.
|
|
53
66
|
|
|
54
|
-
2. **
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
2. **The two numbers must agree.** Your stall wake MUST equal `--stall-signal`'s
|
|
68
|
+
quiet window. Both default to 15 s, so setting `15` and passing no inline
|
|
69
|
+
value is correct. If you pass a custom triple
|
|
70
|
+
(`--stall-signal keepaliveMs:quietMs:stallMs`), your stall wake must equal
|
|
71
|
+
the `stallMs` you passed. Too short and it fires on nothing; too long and it
|
|
72
|
+
never fires on a real message. **Do not use 30** — that number predates this
|
|
73
|
+
mode and is simply wrong for it, not safer.
|
|
59
74
|
|
|
60
75
|
3. **NEVER read the last `PATCHCORD:` line in the task output as news.** It is
|
|
61
|
-
scrollback
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
scrollback and it may be a line you already handled. Two lines look almost
|
|
77
|
+
alike: a live arrival, and a `... waiting in inbox (snapshot at HH:MM:SS)`
|
|
78
|
+
written once when the listener connected and drained the queue. The
|
|
79
|
+
timestamp on the second is there so you can see it is old — check it. In a
|
|
80
|
+
real jcode session an agent re-announced that same drain line repeatedly and
|
|
81
|
+
missed five actual messages while doing so. On every wake call
|
|
82
|
+
`mcp__patchcord__inbox` and believe only that. If it is empty, say nothing
|
|
83
|
+
and go back to waiting.
|
|
84
|
+
|
|
85
|
+
4. **If the inbox is empty, check the listener is still alive** before assuming
|
|
86
|
+
a harmless false wake. A dead listener produces the same silence. If a
|
|
87
|
+
restart is refused with `already running (pid N)`, the process outlived the
|
|
88
|
+
task that tracked it: restart with `--replace`, which is the only sanctioned
|
|
89
|
+
way to remove a running listener. Never `kill`/`pkill` by hand.
|
|
90
|
+
|
|
91
|
+
**Only if your harness has no stall setting at all** does this degrade to a
|
|
92
|
+
timed poll. Say so plainly to the user in that case — tell them you will check
|
|
93
|
+
regularly, never that you will be woken "as messages arrive".
|
|
74
94
|
|
|
75
95
|
# When a notification fires
|
|
76
96
|
|