patchcord 0.6.44 → 0.6.46

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.44",
4
+ "version": "0.6.46",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
@@ -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.44",
4
+ "version": "0.6.46",
5
5
  "description": "Cross-machine agent messaging for Claude Code and Codex",
6
6
  "author": {
7
7
  "name": "ppravdin",
package/harnesses.json CHANGED
@@ -25,7 +25,9 @@
25
25
  "harnesses": [
26
26
  {
27
27
  "id": "claude_code",
28
- "aliases": ["claude"],
28
+ "aliases": [
29
+ "claude"
30
+ ],
29
31
  "cli": "claude",
30
32
  "kind": "terminal",
31
33
  "installer_scope": "project",
@@ -75,7 +77,10 @@
75
77
  },
76
78
  {
77
79
  "id": "grok",
78
- "aliases": ["grok_cli", "grok_build"],
80
+ "aliases": [
81
+ "grok_cli",
82
+ "grok_build"
83
+ ],
79
84
  "cli": "grok",
80
85
  "kind": "terminal",
81
86
  "installer_scope": "project",
@@ -130,9 +135,9 @@
130
135
  "cli": "hermes",
131
136
  "kind": "terminal",
132
137
  "installer_scope": "env-directed",
133
- "installer_config": "$HERMES_HOME/config.yaml if set, else the output of `hermes config path` if the binary resolves, else ~/.hermes/config.yaml",
138
+ "installer_config": "$HERMES_HOME/config.yaml if set, else the output of `hermes config path` if the binary resolves (which reflects the ambient profile), else ~/.hermes/config.yaml",
134
139
  "harness_scope": "per-profile-or-per-home",
135
- "installer_defect": "WAS: the path was hardcoded to ~/.hermes/config.yaml. The installer never called `hermes config path` and did not know profiles exist, so installing while a non-default profile was active wrote the DEFAULT profile's config — the wrong file, silently. FIXED (scripts/lib/hermes-home.mjs, resolveHermesHome/resolveHermesConfigPath/resolveHermesSkillsDest, all three deriving from the same resolved home): HERMES_HOME from the environment, else a shelled `hermes config path` (which itself honours HERMES_HOME — the child inherits the environment), else the unchanged ~/.hermes fallback. Applies to config.yaml AND to where Hermes skills install (~/.hermes/skills/integrations was hardcoded in three more places, unaware of HERMES_HOME — a seat isolated by home for identity but not for skills is not isolated: skills are a guard, not documentation). mux measured two independent isolation mechanisms this way — HERMES_HOME and -p/--profile, both real, both giving a separate mcp_servers AND a separate .env — which is why harness_scope reads per-profile-or-per-home rather than per-profile alone. installer_scope changed from 'global' to 'env-directed' for the same reason: our installer's write target is no longer a fixed machine-wide path, it is wherever HERMES_HOME points when mux sets it, falling back to global only when nothing directs it. PROFILE SUPPORT DELIBERATELY NOT BUILT: -p/--profile is a second, separate isolation axis this installer does not resolve, understand, or accept a flag for. Whether `hermes config path`'s output already reflects an ambient active profile (e.g. via an env var hermes itself reads) is unknown without reading Hermes's own source or an install to test against neither available here. If a user runs Hermes under a non-default profile without ALSO setting HERMES_HOME to match, this installer's write may land in a config that profile-launched Hermes never reads — the same silent-wrong-file failure this whole fix exists to close, just on the axis we chose not to resolve. Not yet released or observed end to end; version not bumped.",
140
+ "installer_defect": "WAS: the path was hardcoded to ~/.hermes/config.yaml. The installer never called `hermes config path` and did not know profiles exist, so installing while a non-default profile was active wrote the DEFAULT profile's config — the wrong file, silently. FIXED (scripts/lib/hermes-home.mjs, resolveHermesHome/resolveHermesConfigPath/resolveHermesSkillsDest, all three deriving from the same resolved home): HERMES_HOME from the environment, else a shelled `hermes config path` (which itself honours HERMES_HOME — the child inherits the environment), else the unchanged ~/.hermes fallback. Applies to config.yaml AND to where Hermes skills install (~/.hermes/skills/integrations was hardcoded in three more places, unaware of HERMES_HOME — a seat isolated by home for identity but not for skills is not isolated: skills are a guard, not documentation). mux measured two independent isolation mechanisms this way — HERMES_HOME and -p/--profile, both real, both giving a separate mcp_servers AND a separate .env — which is why harness_scope reads per-profile-or-per-home rather than per-profile alone. installer_scope changed from 'global' to 'env-directed' for the same reason: our installer's write target is no longer a fixed machine-wide path, it is wherever HERMES_HOME points when mux sets it, falling back to global only when nothing directs it. PROFILE AXIS: COVERED, AND MEASURED RATHER THAN CLAIMED. This entry previously said profile support was deliberately not built, and that a user on a non-default -p/--profile without a matching HERMES_HOME would still get the silent-wrong-file failure. That was WRONG and lead@mux-v2 disproved it on a real Hermes install (boca, 2026-08-20): `hermes profile use sticky1` then `hermes config path` answers $HERMES_HOME/profiles/sticky1/config.yaml. The shelled resolver therefore honours the ambient profile by construction, because Hermes resolves it, not us. The same measurement confirmed the output is a FILE path, so dirname() in resolveHermesHome is correct. Resolution order in full: HERMES_HOME set (a mux seat) uses it directly and the profile is correctly ignored; HERMES_HOME unset with a profile active gets the profile's config; no hermes binary falls back to ~/.hermes, and a host with no hermes has no hermes seat. Under a sticky profile skills land at .../profiles/<name>/skills/integrations - profile-local skills beside profile-local config, which is the intent. Shipped in 0.6.44.",
136
141
  "listener": {
137
142
  "wake": "realtime",
138
143
  "mechanism": "webhook-bridge",
@@ -146,7 +151,9 @@
146
151
  },
147
152
  {
148
153
  "id": "antigravity",
149
- "aliases": ["agy"],
154
+ "aliases": [
155
+ "agy"
156
+ ],
150
157
  "cli": "antigravity",
151
158
  "kind": "terminal",
152
159
  "installer_scope": "project",
@@ -186,7 +193,13 @@
186
193
  "installer_scope": "global",
187
194
  "installer_config": "~/.codeium/windsurf/mcp_config.json",
188
195
  "harness_scope": "unknown",
189
- "listener": { "wake": "none", "mechanism": null, "self_arm": false, "survives_wake": null, "evidence": "declared" }
196
+ "listener": {
197
+ "wake": "none",
198
+ "mechanism": null,
199
+ "self_arm": false,
200
+ "survives_wake": null,
201
+ "evidence": "declared"
202
+ }
190
203
  },
191
204
  {
192
205
  "id": "vscode",
@@ -196,7 +209,13 @@
196
209
  "installer_scope": "project",
197
210
  "installer_config": ".vscode/mcp.json",
198
211
  "harness_scope": "unknown",
199
- "listener": { "wake": "none", "mechanism": null, "self_arm": false, "survives_wake": null, "evidence": "declared" }
212
+ "listener": {
213
+ "wake": "none",
214
+ "mechanism": null,
215
+ "self_arm": false,
216
+ "survives_wake": null,
217
+ "evidence": "declared"
218
+ }
200
219
  },
201
220
  {
202
221
  "id": "zed",
@@ -206,7 +225,13 @@
206
225
  "installer_scope": "global",
207
226
  "installer_config": "~/.config/zed/settings.json",
208
227
  "harness_scope": "unknown",
209
- "listener": { "wake": "none", "mechanism": null, "self_arm": false, "survives_wake": null, "evidence": "declared" }
228
+ "listener": {
229
+ "wake": "none",
230
+ "mechanism": null,
231
+ "self_arm": false,
232
+ "survives_wake": null,
233
+ "evidence": "declared"
234
+ }
210
235
  },
211
236
  {
212
237
  "id": "cline",
@@ -216,7 +241,13 @@
216
241
  "installer_scope": "global",
217
242
  "installer_config": "cline_mcp_settings.json (VS Code globalStorage)",
218
243
  "harness_scope": "unknown",
219
- "listener": { "wake": "none", "mechanism": null, "self_arm": false, "survives_wake": null, "evidence": "declared" }
244
+ "listener": {
245
+ "wake": "none",
246
+ "mechanism": null,
247
+ "self_arm": false,
248
+ "survives_wake": null,
249
+ "evidence": "declared"
250
+ }
220
251
  },
221
252
  {
222
253
  "id": "jcode",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.44",
3
+ "version": "0.6.46",
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; report it, do not respawn.
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
@@ -48,28 +48,36 @@
48
48
  // SAME resolved home (resolveHermesHome), never computed independently —
49
49
  // the two must never be able to disagree about which home they mean.
50
50
  //
51
- // NOT VERIFIED END TO END: no Hermes install on this machine to observe
52
- // `hermes config path`'s actual output shape. Assumed to print the config
53
- // FILE path directly (its name is "config path", not "profile path" or
54
- // "home"), so step 2 takes dirname() of it as the home rather than
55
- // assuming a directory was printed. If that assumption is wrong, the whole
56
- // chain (config.yaml AND skills) is wrong together under step 2 rather
57
- // than disagreeing with each other — a shared, nameable failure mode
58
- // instead of a silent split.
51
+ // VERIFIED, ON A REAL INSTALL. lead@mux-v2 measured both open questions on
52
+ // boca (hermes installed, 2026-08-20) and answered them:
59
53
  //
60
- // PROFILES (-p/--profile) ARE DELIBERATELY NOT SUPPORTED HERE. mux measured
61
- // that Hermes parses -p/--profile even though --help does not list it, and
62
- // that each profile gets its own config.yaml AND its own .env — a second,
63
- // separate isolation mechanism from HERMES_HOME. This module resolves
64
- // HERMES_HOME only. No --hermes-profile flag exists, and none is planned
65
- // without first reading Hermes's own source or testing against a real
66
- // install to learn whether `hermes config path`'s output already reflects
67
- // an ambient active profile neither is available here, so the honest
68
- // choice was not to guess. CONSEQUENCE A USER MUST KNOW: running Hermes
69
- // under a non-default profile without ALSO setting HERMES_HOME to match
70
- // means this installer's write may land in a config that profile-launched
71
- // Hermes never reads — the exact silent-wrong-file failure the rest of this
72
- // module exists to close, just on the axis this module does not resolve.
54
+ // $ hermes config path
55
+ // /home/p/.hermes/config.yaml <- a FILE path, so dirname() is right
56
+ //
57
+ // $ hermes profile use sticky1 <- sets the ambient sticky profile
58
+ // $ hermes config path
59
+ // $HERMES_HOME/profiles/sticky1/config.yaml <- the PROFILE is honoured
60
+ //
61
+ // PROFILES (-p/--profile) ARE COVERED, BY CONSTRUCTION RATHER THAN BY CODE.
62
+ // This comment previously said the opposite: that profiles were a second
63
+ // isolation axis this module does not resolve, and that a user on a
64
+ // non-default profile without a matching HERMES_HOME would still get the
65
+ // silent-wrong-file failure. That was wrong. Hermes resolves the ambient
66
+ // profile itself, and step 2 shells Hermes, so the answer arrives already
67
+ // correct. The decision NOT to model Hermes's resolution and to ask the
68
+ // binary instead is what covers an axis nobody wrote code for.
69
+ //
70
+ // Resolution order, in full:
71
+ // HERMES_HOME set (a mux seat) -> used directly; the profile is correctly
72
+ // ignored, because the seat's home wins
73
+ // HERMES_HOME unset, profile on -> `hermes config path` answers with the
74
+ // profile's own config
75
+ // no hermes binary -> ~/.hermes, and a host without hermes
76
+ // has no hermes seat to get wrong
77
+ //
78
+ // One consequence to keep rather than fix: under a sticky profile, dirname()
79
+ // puts skills at .../profiles/<name>/skills/integrations. Profile-local
80
+ // skills beside profile-local config is coherent, and it is what we want.
73
81
 
74
82
  import { execSync } from "node:child_process";
75
83
  import { join, dirname } from "node:path";
@@ -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
+ }
@@ -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
@@ -310,6 +312,56 @@ async function drainQueueOnce(baseUrl, token) {
310
312
  throw lastErr;
311
313
  }
312
314
 
315
+ // --replace: the running listener is not the problem, being UNABLE TO REPLACE
316
+ // IT is. On a harness whose background-task tracking can be lost (jcode: a
317
+ // restart whose cancel was rejected as stale leaves the process alive but no
318
+ // longer attached to a stall wake), the listener keeps receiving and can no
319
+ // longer wake anybody — and because it still holds the pidfile, the correct
320
+ // respawn is refused with `already running`. The agent is then stuck between
321
+ // a guard doing its job and a skill that forbids reaching for kill.
322
+ //
323
+ // So the recovery is a flag rather than instructions: `patchcord subscribe
324
+ // --replace` terminates the pidfile's holder and takes over.
325
+ //
326
+ // IT ONLY EVER SIGNALS A PID READ FROM OUR OWN PIDFILE PATH, and only after
327
+ // confirming that pid is one of ours. PIDs are reused: a file left by a
328
+ // crashed listener can name a process that now belongs to somebody else, and
329
+ // killing it because a stale file pointed there would be far worse than the
330
+ // stuck state this fixes. When the identity cannot be confirmed the file is
331
+ // treated as stale and taken over WITHOUT signalling anything.
332
+ const REPLACE_MODE = process.argv.includes("--replace");
333
+
334
+ /** SIGTERM, then SIGKILL if it is still there. Returns true once the pid is
335
+ * gone. Bounded: it must not hang a listener start forever. */
336
+ function terminateAndWait(pid, deadlineMs = 3000) {
337
+ const isGone = () => {
338
+ try {
339
+ process.kill(pid, 0);
340
+ return false;
341
+ } catch (_) {
342
+ return true;
343
+ }
344
+ };
345
+ try {
346
+ process.kill(pid, "SIGTERM");
347
+ } catch (_) {
348
+ return true;
349
+ }
350
+ const started = Date.now();
351
+ while (Date.now() - started < deadlineMs) {
352
+ if (isGone()) return true;
353
+ try {
354
+ // Busy-wait deliberately: this runs once, before the socket opens, and
355
+ // an async sleep here would let the rest of startup race the takeover.
356
+ execSync("sleep 0.1");
357
+ } catch (_) {}
358
+ }
359
+ try {
360
+ process.kill(pid, "SIGKILL");
361
+ } catch (_) {}
362
+ return isGone();
363
+ }
364
+
313
365
  function writePidfile(path) {
314
366
  try {
315
367
  writeFileSync(path, String(process.pid), { flag: "wx" });
@@ -321,7 +373,29 @@ function writePidfile(path) {
321
373
  if (existingPid && existingPid !== process.pid) {
322
374
  try {
323
375
  process.kill(existingPid, 0);
324
- die(`already running (pid ${existingPid})`, 2);
376
+ if (REPLACE_MODE) {
377
+ const action = decidePidfileAction({
378
+ existingPid,
379
+ selfPid: process.pid,
380
+ isAlive: true,
381
+ isOurs: isOurSubscribeProcess(existingPid),
382
+ });
383
+ if (action === "takeover") {
384
+ // A live pid that is NOT ours means the file is stale and the
385
+ // number was reused. Take the file; signal nothing.
386
+ logErr(`subscribe: --replace: pid ${existingPid} is not a patchcord listener — treating the pidfile as stale`);
387
+ } else if (terminateAndWait(existingPid)) {
388
+ logErr(`subscribe: --replace: terminated the previous listener (pid ${existingPid})`);
389
+ } else {
390
+ die(`--replace: could not terminate the previous listener (pid ${existingPid})`, 2);
391
+ }
392
+ try {
393
+ unlinkSync(path);
394
+ } catch (_) {}
395
+ writeFileSync(path, String(process.pid), { flag: "wx" });
396
+ return;
397
+ }
398
+ die(`already running (pid ${existingPid}) — use \`patchcord subscribe --replace\` to take over`, 2);
325
399
  } catch (_) {
326
400
  // stale
327
401
  try {