patchcord 0.6.46 → 0.6.48

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "patchcord",
3
3
  "description": "Cross-machine agent messaging. Messages from other agents land in the inbox and wake the agent to reply.",
4
- "version": "0.6.46",
4
+ "version": "0.6.48",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.43",
3
+ "version": "0.6.48",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "ppravdin",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "patchcord",
4
- "version": "0.6.46",
4
+ "version": "0.6.48",
5
5
  "description": "Cross-machine agent messaging for Claude Code and Codex",
6
6
  "author": {
7
7
  "name": "ppravdin",
@@ -44,33 +44,59 @@ 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
- 1. **Set the stall wake.** Whatever your harness calls it — jcode uses
50
- `stall_wake_seconds: 30` — you MUST set it. Without it the background task
51
- is silent by construction and you get zero notifications, no error, and no
52
- sign that anything is wrong.
50
+ ```
51
+ patchcord subscribe --stall-signal
52
+ ```
53
53
 
54
- 2. **Understand what the wake means, and what it does not.** The stall fires
55
- because the pipe went QUIET, not because a message arrived. The `grep` filter
56
- in step 2 drops the 30 s heartbeats, which is exactly what makes the pipe
57
- look idle so you will be woken on a timer, whether or not anything came in.
58
- Treat every wake as "go and check", never as "there is a message".
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
+ **Run it raw. Do not pipe it through `grep`.** Step 2's command filters
65
+ stdout down to `^PATCHCORD:` lines, and that filter removes the keepalives
66
+ this mode depends on. Your harness measures the stall on the OUTPUT of the
67
+ pipeline, so a filtered stream is silent all the time: the wake fires on
68
+ nothing every 15 s, and the deliberate quiet after a real message looks
69
+ exactly like idle. Filtering here does not merely add noise, it deletes the
70
+ signal. Ignore the `HEARTBEAT:` lines when you read the output; they exist
71
+ for the stall detector, not for you.
72
+
73
+ 2. **The two numbers must agree.** Your stall wake MUST equal `--stall-signal`'s
74
+ quiet window. Both default to 15 s, so setting `15` and passing no inline
75
+ value is correct. If you pass a custom triple
76
+ (`--stall-signal keepaliveMs:quietMs:stallMs`), your stall wake must equal
77
+ the `stallMs` you passed. Too short and it fires on nothing; too long and it
78
+ never fires on a real message. **Do not use 30** — that number predates this
79
+ mode and is simply wrong for it, not safer.
59
80
 
60
81
  3. **NEVER read the last `PATCHCORD:` line in the task output as news.** It is
61
- scrollback. It may be the same line you already handled minutes ago. The
62
- inbox is the source of truth: on each wake call `mcp__patchcord__inbox`, and
63
- if it is empty, say nothing and go back to waiting. An agent that announces a
64
- message because it re-read an old line is worse than one that misses it.
65
-
66
- 4. **The noise is the cost of delivery, and it is the right trade.** A wake
67
- every N seconds with nothing to report is cheap. A missed message is not.
68
- Raise the interval if the user asks; do not remove it.
69
-
70
- This degrades push into a timed poll on those harnesses. That is honest and it
71
- works. Do not pretend otherwise to the user, and do not tell them you will be
72
- woken "as messages arrive" if your harness cannot do that say you will check
73
- regularly.
82
+ scrollback and it may be a line you already handled. Two lines look almost
83
+ alike: a live arrival, and a `... waiting in inbox (snapshot at HH:MM:SS)`
84
+ written once when the listener connected and drained the queue. The
85
+ timestamp on the second is there so you can see it is old — check it. In a
86
+ real jcode session an agent re-announced that same drain line repeatedly and
87
+ missed five actual messages while doing so. On every wake call
88
+ `mcp__patchcord__inbox` and believe only that. If it is empty, say nothing
89
+ and go back to waiting.
90
+
91
+ 4. **If the inbox is empty, check the listener is still alive** before assuming
92
+ a harmless false wake. A dead listener produces the same silence. If a
93
+ restart is refused with `already running (pid N)`, the process outlived the
94
+ task that tracked it: restart with `--replace`, which is the only sanctioned
95
+ way to remove a running listener. Never `kill`/`pkill` by hand.
96
+
97
+ **Only if your harness has no stall setting at all** does this degrade to a
98
+ timed poll. Say so plainly to the user in that case — tell them you will check
99
+ regularly, never that you will be woken "as messages arrive".
74
100
 
75
101
  # When a notification fires
76
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.46",
3
+ "version": "0.6.48",
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"
@@ -31,7 +31,7 @@ path.
31
31
 
32
32
  ```
33
33
  Bash(
34
- command: "patchcord subscribe --stall-signal | grep --line-buffered '^PATCHCORD:'; exit ${PIPESTATUS[0]}",
34
+ command: "patchcord subscribe --stall-signal",
35
35
  run_in_background: true,
36
36
  stall_wake_seconds: 15
37
37
  )
@@ -46,10 +46,19 @@ path.
46
46
  number predates `--stall-signal` and is shorter than nothing: it is
47
47
  simply the wrong number for this mechanism, not a safer one.
48
48
 
49
- The grep keeps only `PATCHCORD:` lines in the visible output; `HEARTBEAT:`
50
- lines never match that pattern, so they never need special handling on
51
- your end. `${PIPESTATUS[0]}` preserves subscribe's own exit code through
52
- the pipe.
49
+ **DO NOT PIPE THIS THROUGH `grep`.** Every other subscribe command in this
50
+ plugin filters stdout down to `^PATCHCORD:` lines, and doing that here
51
+ breaks the entire mechanism: your harness measures the stall on what comes
52
+ OUT of the pipeline, and `grep` removes exactly the `HEARTBEAT:` keepalives
53
+ that are supposed to keep the pipe looking alive. Filtered, the stream is
54
+ silent all the time, so the wake fires on nothing every 15 s and the quiet
55
+ window after a real message is indistinguishable from idle. That is the
56
+ noisy, useless wake loop `--stall-signal` exists to replace.
57
+
58
+ So the raw stream is what the background task must see. It carries
59
+ `HEARTBEAT: <timestamp>` every 5 s and `PATCHCORD: ...` on a real message.
60
+ Ignore the heartbeats when you read the output; they are for the stall
61
+ detector, not for you.
53
62
 
54
63
  3. **Tell the user one line:** "Patchcord listener active — I'll check
55
64
  regularly for new messages." Do not say "as messages arrive" — the
@@ -83,7 +92,7 @@ cannot tell which one happened from the wake alone:
83
92
 
84
93
  ```
85
94
  Bash(
86
- command: "patchcord subscribe --replace --stall-signal | grep --line-buffered '^PATCHCORD:'; exit ${PIPESTATUS[0]}",
95
+ command: "patchcord subscribe --replace --stall-signal",
87
96
  run_in_background: true,
88
97
  stall_wake_seconds: 15
89
98
  )
@@ -21,8 +21,21 @@
21
21
  // pidfile — all things subscribe.mjs does as soon as it runs.
22
22
  //
23
23
  // THE KEEPALIVE LINE ITSELF (written by subscribe.mjs, not here) starts with
24
- // "HEARTBEAT:", which does not match `^PATCHCORD:` — the grep filter every
25
- // subscribe skill uses already drops it for free.
24
+ // "HEARTBEAT:", which does not match `^PATCHCORD:`.
25
+ //
26
+ // THAT USED TO BE DESCRIBED HERE AS A CONVENIENCE — the grep every other
27
+ // subscribe skill uses "drops it for free". It is the opposite, and the
28
+ // mistake cost a working listener in production: the harness measures the
29
+ // stall on what comes OUT of the pipeline, so a `| grep '^PATCHCORD:'` in
30
+ // front of a --stall-signal listener deletes every keepalive before anything
31
+ // can see it. The stream is then silent all the time, the wake fires on
32
+ // nothing every stall window, and the deliberate quiet after a real message
33
+ // is indistinguishable from idle. Filtering does not add noise here, it
34
+ // removes the signal.
35
+ //
36
+ // So the keepalive prefix exists to be IGNORED BY A READER, not removed by a
37
+ // filter. --stall-signal must be run raw. Every skill that documents it is
38
+ // pinned to that by tests in tests/test-stall-signal.mjs.
26
39
 
27
40
  export const STALL_SIGNAL_DEFAULTS = Object.freeze({
28
41
  // Written to stdout on this interval while idle. Must stay well under
@@ -53,10 +53,12 @@ const STALL_SIGNAL_MODE = STALL_SIGNAL_OPTS !== null;
53
53
  if (STALL_SIGNAL_MODE && HERMES_MODE) {
54
54
  die("--stall-signal and --hermes are mutually exclusive — each is its own wake mechanism, pick one");
55
55
  }
56
- // The keepalive line's own prefix, chosen so it can never collide with a
57
- // real notification: every subscribe skill's grep is `^PATCHCORD:`, and
58
- // "HEARTBEAT:" does not start with "PATCHCORD" at all, so nothing else needs
59
- // to change for a consumer that already filters on that pattern.
56
+ // The keepalive line's own prefix, chosen so it can never collide with a real
57
+ // notification. It must be IGNORED by whoever reads the output, and never
58
+ // FILTERED OUT of it: a `| grep '^PATCHCORD:'` in front of a --stall-signal
59
+ // listener removes exactly the lines that keep the pipe from looking idle,
60
+ // which turns wake-on-message back into wake-on-silence. See the header of
61
+ // scripts/lib/stall-signal.mjs.
60
62
  const STALL_SIGNAL_KEEPALIVE_PREFIX = "HEARTBEAT:";
61
63
  // Suppress keepalives until this timestamp (ms since epoch). 0 = never
62
64
  // suppressed. Set by notify() on every real message.
@@ -295,7 +297,20 @@ async function drainQueueOnce(baseUrl, token) {
295
297
  count = JSON.parse(res.body).pending_count ?? 0;
296
298
  } catch (_) {}
297
299
  if (count > 0) {
298
- await notify(`PATCHCORD: ${count} waiting in inbox`, { count, kind: "pending" });
300
+ // SELF-DATING, DELIBERATELY. This line and a live-arrival line both
301
+ // start with "PATCHCORD:" and both stay in the task output forever.
302
+ // On a harness that wakes on a stall, the agent re-reads the tail of
303
+ // that output on every wake, and an undated snapshot from twenty
304
+ // minutes ago is indistinguishable from a message that just landed -
305
+ // reported in a jcode session where five real messages were missed
306
+ // while the same old line was announced repeatedly as news.
307
+ // The clock is what makes a re-read visibly stale.
308
+ const at = new Date().toTimeString().slice(0, 8);
309
+ await notify(`PATCHCORD: ${count} waiting in inbox (snapshot at ${at})`, {
310
+ count,
311
+ kind: "pending",
312
+ at,
313
+ });
299
314
  }
300
315
  return;
301
316
  } catch (e) {
@@ -489,6 +504,13 @@ async function run() {
489
504
  `subscribe: stall-signal keepalive=${STALL_SIGNAL_OPTS.keepaliveMs}ms ` +
490
505
  `quiet=${STALL_SIGNAL_OPTS.quietMs}ms stall=${STALL_SIGNAL_OPTS.stallMs}ms`
491
506
  );
507
+ // Said out loud on every start, because the mistake is invisible once
508
+ // made: a filtered stream looks like a working listener that simply never
509
+ // wakes anyone, which is exactly what was reported from production.
510
+ logErr(
511
+ "subscribe: stall-signal writes HEARTBEAT: lines to stdout — do NOT pipe " +
512
+ "this through `grep '^PATCHCORD:'`, it deletes them and the wake stops working"
513
+ );
492
514
  stallSignalTimer = setInterval(() => {
493
515
  if (Date.now() >= stallSignalQuietUntil) {
494
516
  process.stdout.write(`${STALL_SIGNAL_KEEPALIVE_PREFIX} ${new Date().toISOString()}\n`);
@@ -44,33 +44,59 @@ 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
- 1. **Set the stall wake.** Whatever your harness calls it — jcode uses
50
- `stall_wake_seconds: 30` — you MUST set it. Without it the background task
51
- is silent by construction and you get zero notifications, no error, and no
52
- sign that anything is wrong.
50
+ ```
51
+ patchcord subscribe --stall-signal
52
+ ```
53
53
 
54
- 2. **Understand what the wake means, and what it does not.** The stall fires
55
- because the pipe went QUIET, not because a message arrived. The `grep` filter
56
- in step 2 drops the 30 s heartbeats, which is exactly what makes the pipe
57
- look idle so you will be woken on a timer, whether or not anything came in.
58
- Treat every wake as "go and check", never as "there is a message".
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
+ **Run it raw. Do not pipe it through `grep`.** Step 2's command filters
65
+ stdout down to `^PATCHCORD:` lines, and that filter removes the keepalives
66
+ this mode depends on. Your harness measures the stall on the OUTPUT of the
67
+ pipeline, so a filtered stream is silent all the time: the wake fires on
68
+ nothing every 15 s, and the deliberate quiet after a real message looks
69
+ exactly like idle. Filtering here does not merely add noise, it deletes the
70
+ signal. Ignore the `HEARTBEAT:` lines when you read the output; they exist
71
+ for the stall detector, not for you.
72
+
73
+ 2. **The two numbers must agree.** Your stall wake MUST equal `--stall-signal`'s
74
+ quiet window. Both default to 15 s, so setting `15` and passing no inline
75
+ value is correct. If you pass a custom triple
76
+ (`--stall-signal keepaliveMs:quietMs:stallMs`), your stall wake must equal
77
+ the `stallMs` you passed. Too short and it fires on nothing; too long and it
78
+ never fires on a real message. **Do not use 30** — that number predates this
79
+ mode and is simply wrong for it, not safer.
59
80
 
60
81
  3. **NEVER read the last `PATCHCORD:` line in the task output as news.** It is
61
- scrollback. It may be the same line you already handled minutes ago. The
62
- inbox is the source of truth: on each wake call `mcp__patchcord__inbox`, and
63
- if it is empty, say nothing and go back to waiting. An agent that announces a
64
- message because it re-read an old line is worse than one that misses it.
65
-
66
- 4. **The noise is the cost of delivery, and it is the right trade.** A wake
67
- every N seconds with nothing to report is cheap. A missed message is not.
68
- Raise the interval if the user asks; do not remove it.
69
-
70
- This degrades push into a timed poll on those harnesses. That is honest and it
71
- works. Do not pretend otherwise to the user, and do not tell them you will be
72
- woken "as messages arrive" if your harness cannot do that say you will check
73
- regularly.
82
+ scrollback and it may be a line you already handled. Two lines look almost
83
+ alike: a live arrival, and a `... waiting in inbox (snapshot at HH:MM:SS)`
84
+ written once when the listener connected and drained the queue. The
85
+ timestamp on the second is there so you can see it is old — check it. In a
86
+ real jcode session an agent re-announced that same drain line repeatedly and
87
+ missed five actual messages while doing so. On every wake call
88
+ `mcp__patchcord__inbox` and believe only that. If it is empty, say nothing
89
+ and go back to waiting.
90
+
91
+ 4. **If the inbox is empty, check the listener is still alive** before assuming
92
+ a harmless false wake. A dead listener produces the same silence. If a
93
+ restart is refused with `already running (pid N)`, the process outlived the
94
+ task that tracked it: restart with `--replace`, which is the only sanctioned
95
+ way to remove a running listener. Never `kill`/`pkill` by hand.
96
+
97
+ **Only if your harness has no stall setting at all** does this degrade to a
98
+ timed poll. Say so plainly to the user in that case — tell them you will check
99
+ regularly, never that you will be woken "as messages arrive".
74
100
 
75
101
  # When a notification fires
76
102