patchcord 0.6.43 → 0.6.44

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.43",
4
+ "version": "0.6.44",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.40",
3
+ "version": "0.6.43",
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.43",
4
+ "version": "0.6.44",
5
5
  "description": "Cross-machine agent messaging for Claude Code and Codex",
6
6
  "author": {
7
7
  "name": "ppravdin",
@@ -30,6 +30,48 @@ User invoked /patchcord:subscribe — do NOT substitute `wait_for_message()`. Sp
30
30
 
31
31
  3. **Tell the user one line:** *"Patchcord listener active — I'll pick up new messages as they arrive."*
32
32
 
33
+ # IF YOUR HARNESS HAS NO `Monitor` TOOL — READ THIS BEFORE STEP 2
34
+
35
+ `Monitor` is a Claude Code tool. Step 2 above assumes it, and the assumption is
36
+ load-bearing rather than stylistic: Monitor turns EVERY stdout line into a
37
+ notification, which is what makes a message arriving wake you.
38
+
39
+ Other harnesses run a background command instead (`bash` with
40
+ `run_in_background: true`, or your harness's equivalent), and those typically
41
+ notify on a STALL — no output for N seconds — not on each line. On such a
42
+ harness, following step 2 verbatim produces a listener that WORKS and NEVER
43
+ WAKES YOU: the pipe fills with messages you are never told about, and you
44
+ discover them only when the user asks why you did not answer. That has happened
45
+ in production, on jcode, and is the reason this section exists.
46
+
47
+ So, if you have no `Monitor`:
48
+
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.
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".
59
+
60
+ 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.
74
+
33
75
  # When a notification fires
34
76
 
35
77
  Monitor surfaces `PATCHCORD: 1 new from <sender>`:
package/bin/patchcord.mjs CHANGED
@@ -13,6 +13,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
13
13
  const pluginRoot = join(__dirname, "..");
14
14
  import { resolveProjectBearer, harnessContext } from "../scripts/lib/resolve-project-bearer.mjs";
15
15
  import { detectClaudeLocalMcpOverride } from "../scripts/lib/claude-local-mcp.mjs";
16
+ import { resolveHermesConfigPath, resolveHermesSkillsDest } from "../scripts/lib/hermes-home.mjs";
16
17
  const cmd = process.argv[2];
17
18
 
18
19
  /** Write a file then chmod 0600 (token-bearing configs must not be group/world readable). */
@@ -643,7 +644,7 @@ async function _resolveBearer(options = {}) {
643
644
  () => readJsonAt(join(HOME, ".gemini", "settings.json"), ["mcpServers", "patchcord"], "gemini"),
644
645
  () => readJsonAt(zedPath, ["context_servers", "patchcord"], "zed"),
645
646
  () => readJsonAt(join(HOME, ".openclaw", "openclaw.json"), ["mcp", "servers", "patchcord"], "openclaw"),
646
- () => readHermesShape(join(HOME, ".hermes", "config.yaml")),
647
+ () => readHermesShape(resolveHermesConfigPath()),
647
648
  ...clinePaths.map((p) => () => readJsonAt(p, ["mcpServers", "patchcord"], "cline")),
648
649
  ];
649
650
  const globalCandidates = defaultGlobalCandidates;
@@ -1556,7 +1557,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1556
1557
  // prints. NOT VERIFIED END TO END from here: no jcode on this machine,
1557
1558
  // and proving it needs a live token against the real endpoint.
1558
1559
  const jdir = join(dir, ".jcode"); mkdirSync(jdir, { recursive: true });
1559
- return writeJson(join(jdir, "mcp.json"), (o) => {
1560
+ const jcodeWritten = writeJson(join(jdir, "mcp.json"), (o) => {
1560
1561
  o.mcpServers = o.mcpServers || {};
1561
1562
  // No `type` key at all. jcode infers stdio from the presence of
1562
1563
  // `command`, and writing "type": "stdio" is one more string to get
@@ -1573,16 +1574,54 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1573
1574
  ],
1574
1575
  };
1575
1576
  });
1577
+ // THE SKILL IS A GUARD, NOT DOCUMENTATION, AND THIS IS THE HARNESS THAT
1578
+ // PROVED WHY. jcode inherits Claude Code's plugin skills today, which
1579
+ // say "spawn the listener under Monitor" — jcode has no Monitor, so
1580
+ // that instruction produces a listener that connects, receives, and
1581
+ // never wakes the agent. Observed in production: two messages sat
1582
+ // unread behind a healthy-looking listener. Without jcode's own
1583
+ // subscribe skill telling it to use `--stall-signal` + a bash
1584
+ // background task instead, every jcode agent silently inherits the
1585
+ // wrong instructions for its own harness.
1586
+ //
1587
+ // GLOBAL, NOT per-project (Pavel's ruling: "we dont need per project
1588
+ // skills, only per project MCP settings needed") — mirrors the hermes
1589
+ // branch above, not the antigravity one. `.jcode/mcp.json` stays
1590
+ // per-project (every project = one namespace = one agent identity);
1591
+ // skills are per machine, installed once, and MUST NOT contain
1592
+ // anything project-specific as a result — no namespace, no agent name,
1593
+ // no path baked in below.
1594
+ //
1595
+ // Destination is FLAT under ~/.jcode/skills/<name>/, not nested under
1596
+ // an "integrations" folder like hermes — jcode's own loader
1597
+ // (crates/jcode-app-core/src/tool/skill.rs) reads
1598
+ // ~/.jcode/skills/<skill-name>/SKILL.md directly, verified from source,
1599
+ // not the hermes shape.
1600
+ try {
1601
+ const jcodeSkillsSrc = join(pluginRoot, "per-project-skills", "jcode");
1602
+ if (existsSync(jcodeSkillsSrc)) {
1603
+ const jcodeSkillsDest = join(HOME, ".jcode", "skills");
1604
+ for (const name of ["inbox", "subscribe", "wait"]) {
1605
+ const from = join(jcodeSkillsSrc, name, "SKILL.md");
1606
+ if (!existsSync(from)) continue;
1607
+ const to = join(jcodeSkillsDest, name);
1608
+ mkdirSync(to, { recursive: true });
1609
+ cpSync(from, join(to, "SKILL.md"));
1610
+ }
1611
+ }
1612
+ } catch {}
1613
+ return jcodeWritten;
1576
1614
  }
1577
1615
  if (tool === "hermes") {
1578
- // Hermes reads MCP servers ONLY from its GLOBAL ~/.hermes/config.yaml
1579
- // (mcp_servers key) — it ignores a project-local .mcp.json. So unlike the
1580
- // other tools we cannot write into `dir`; we upsert the global config,
1581
- // mirroring the `npx patchcord` installer's Hermes path. NOTE: global =
1582
- // one patchcord identity per machine, so provisioning a second hermes
1583
- // worker in another namespace overwrites the first's token. Single-hermes
1584
- // teams only until Hermes gains project-scoped MCP config.
1585
- const hermesPath = join(HOME, ".hermes", "config.yaml");
1616
+ // Hermes reads MCP servers ONLY from its GLOBAL config.yaml (mcp_servers
1617
+ // key) — it ignores a project-local .mcp.json. So unlike the other tools
1618
+ // we cannot write into `dir`; we upsert the global config, mirroring the
1619
+ // `npx patchcord` installer's Hermes path. NOTE: "global" here means one
1620
+ // patchcord identity per Hermes HOME, so provisioning a second hermes
1621
+ // worker under the SAME home overwrites the first's token. Isolated by
1622
+ // HERMES_HOME (see resolveHermesConfigPath) or by -p profile — either
1623
+ // gives a separate home, and therefore a separate identity.
1624
+ const hermesPath = resolveHermesConfigPath();
1586
1625
  mkdirSync(dirname(hermesPath), { recursive: true });
1587
1626
  let existingYaml = "";
1588
1627
  try { existingYaml = existsSync(hermesPath) ? readFileSync(hermesPath, "utf-8") : ""; } catch {}
@@ -1592,7 +1631,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1592
1631
  try {
1593
1632
  const hermesSkillsSrc = join(pluginRoot, "per-project-skills", "hermes");
1594
1633
  if (existsSync(hermesSkillsSrc)) {
1595
- const hermesSkillsDest = join(HOME, ".hermes", "skills", "integrations");
1634
+ const hermesSkillsDest = resolveHermesSkillsDest();
1596
1635
  mkdirSync(hermesSkillsDest, { recursive: true });
1597
1636
  cpSync(hermesSkillsSrc, hermesSkillsDest, { recursive: true });
1598
1637
  }
@@ -2989,11 +3028,14 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
2989
3028
 
2990
3029
  // Hermes — refresh patchcord skills to the current version on every run, so
2991
3030
  // skill fixes land via `patchcord update` (the interactive choice 13 only
2992
- // installs them once). ~/.hermes/skills is Hermes-only, so no leak risk; only
2993
- // refresh when the integrations dir already exists (Hermes was set up before).
3031
+ // installs them once). Hermes's own skills dir is not shared with any
3032
+ // other tool's config, so no leak risk; only refresh when the integrations
3033
+ // dir already exists (Hermes was set up before) — resolved through the
3034
+ // SAME home as config.yaml, so this refresh touches the seat's own skills
3035
+ // under HERMES_HOME, not the operator's global ones.
2994
3036
  {
2995
3037
  const hermesSkillsSrc = join(pluginRoot, "per-project-skills", "hermes");
2996
- const hermesSkillsDest = join(HOME, ".hermes", "skills", "integrations");
3038
+ const hermesSkillsDest = resolveHermesSkillsDest();
2997
3039
  if (existsSync(hermesSkillsDest) && existsSync(hermesSkillsSrc)) {
2998
3040
  let hermesChanged = false;
2999
3041
  for (const name of readdirSync(hermesSkillsSrc)) {
@@ -3762,9 +3804,34 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
3762
3804
  } catch (e) {
3763
3805
  console.log(`\n ${yellow}⚠ Failed to write ${jcodePath}: ${e.message}${r}`);
3764
3806
  }
3807
+ // Install jcode skills to ~/.jcode/skills/<name>/ — GLOBAL, once per
3808
+ // machine, not per project (Pavel's ruling: "we dont need per project
3809
+ // skills, only per project MCP settings needed"). See the
3810
+ // writeWorkerConfig branch above for the full guard-not-documentation
3811
+ // reasoning and why the destination is flat, not nested under
3812
+ // "integrations" the way hermes's is.
3813
+ try {
3814
+ const jcodeSkillsSrc = join(pluginRoot, "per-project-skills", "jcode");
3815
+ if (existsSync(jcodeSkillsSrc)) {
3816
+ const jcodeSkillsDest = join(HOME, ".jcode", "skills");
3817
+ for (const name of ["inbox", "subscribe", "wait"]) {
3818
+ const from = join(jcodeSkillsSrc, name, "SKILL.md");
3819
+ if (!existsSync(from)) continue;
3820
+ const to = join(jcodeSkillsDest, name);
3821
+ mkdirSync(to, { recursive: true });
3822
+ cpSync(from, join(to, "SKILL.md"));
3823
+ }
3824
+ console.log(` ${green}✓${r} jcode skills installed: ${dim}${jcodeSkillsDest}${r}`);
3825
+ }
3826
+ } catch {}
3765
3827
  } else if (isHermes) {
3766
- // Hermes: global only (~/.hermes/config.yaml, YAML, mcp_servers key)
3767
- const hermesPath = join(HOME, ".hermes", "config.yaml");
3828
+ // Hermes: global-per-home only (config.yaml, YAML, mcp_servers key). Same
3829
+ // HERMES_HOME / `hermes config path` / ~/.hermes resolution as the
3830
+ // writeWorkerConfig branch above — see resolveHermesConfigPath. Both
3831
+ // write sites and the bearer-resolution read site must agree, or a seat
3832
+ // provisioned here writes to one file while `patchcord whoami` verifies
3833
+ // against another.
3834
+ const hermesPath = resolveHermesConfigPath();
3768
3835
  mkdirSync(dirname(hermesPath), { recursive: true });
3769
3836
  let existingYaml = "";
3770
3837
  try { existingYaml = existsSync(hermesPath) ? readFileSync(hermesPath, "utf-8") : ""; } catch {}
@@ -3775,11 +3842,12 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
3775
3842
  } catch (e) {
3776
3843
  console.log(`\n ${yellow}⚠ Failed to write ${hermesPath}: ${e.message}${r}`);
3777
3844
  }
3778
- // Install Hermes skills to ~/.hermes/skills/integrations/
3845
+ // Install Hermes skills under the resolved home's skills/integrations/
3846
+ // (same home as config.yaml — see resolveHermesSkillsDest).
3779
3847
  try {
3780
3848
  const hermesSkillsSrc = join(pluginRoot, "per-project-skills", "hermes");
3781
3849
  if (existsSync(hermesSkillsSrc)) {
3782
- const hermesSkillsDest = join(HOME, ".hermes", "skills", "integrations");
3850
+ const hermesSkillsDest = resolveHermesSkillsDest();
3783
3851
  mkdirSync(hermesSkillsDest, { recursive: true });
3784
3852
  cpSync(hermesSkillsSrc, hermesSkillsDest, { recursive: true });
3785
3853
  console.log(` ${green}✓${r} Hermes skills installed: ${dim}${hermesSkillsDest}${r}`);
package/harnesses.json CHANGED
@@ -129,10 +129,10 @@
129
129
  "aliases": [],
130
130
  "cli": "hermes",
131
131
  "kind": "terminal",
132
- "installer_scope": "global",
133
- "installer_config": "~/.hermes/config.yaml",
134
- "harness_scope": "per-profile",
135
- "installer_defect": "The path is hardcoded to ~/.hermes/config.yaml. The installer never calls `hermes config path` and does not know profiles exist, so installing while a non-default profile is active writes the DEFAULT profile's config — the wrong file, silently. Not fixed as of 0.6.41.",
132
+ "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",
134
+ "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.",
136
136
  "listener": {
137
137
  "wake": "realtime",
138
138
  "mechanism": "webhook-bridge",
@@ -227,12 +227,12 @@
227
227
  "installer_config": ".jcode/mcp.json",
228
228
  "harness_scope": "project",
229
229
  "listener": {
230
- "wake": "none",
231
- "mechanism": null,
232
- "self_arm": false,
230
+ "wake": "stall",
231
+ "mechanism": "subscribe-skill-stall-signal",
232
+ "self_arm": true,
233
233
  "survives_wake": null,
234
234
  "evidence": "declared",
235
- "note": "STDIO ONLY, WHICH IS WHY THIS ROW IS NOT LIKE THE OTHERS. jcode reads .jcode/mcp.json, .mcp.json and .claude/mcp.json, so it ALREADY finds the Claude Code entry we write - and drops it, because jcode supports stdio servers only and skips type http/sse at load time with a log line (crates/jcode-base/src/mcp/protocol.rs, retain on is_stdio). So our installer writes a stdio bridge entry instead of the http entry every other harness gets. Two consequences a reader must not undo: the server is named patchcord-jcode, NOT patchcord, because jcode merges those three files with later ones overriding by NAME - a shared name means the day jcode gains http transport, the .mcp.json entry becomes runnable and silently overrides, and jcode starts authenticating as claude_code's agent. And no wake: no subscribe path exists for jcode in this package."
235
+ "note": "STDIO ONLY FOR MCP; SKILLS ARE GLOBAL, NOT PROJECT. jcode reads .jcode/mcp.json, .mcp.json and .claude/mcp.json for MCP config (per-project, unchanged by this note), so it ALREADY finds the Claude Code entry we write - and drops it, because jcode supports stdio servers only and skips type http/sse at load time with a log line (crates/jcode-base/src/mcp/protocol.rs, retain on is_stdio). Server named patchcord-jcode, NOT patchcord, for the same reason codex uses patchcord-codex: a shared name would let a later file's entry silently override by NAME the day jcode gains http transport. jcode's own skills, separately, install ONCE GLOBALLY to ~/.jcode/skills/<name>/SKILL.md (verified from crates/jcode-app-core/src/tool/skill.rs's own loader paths) - not per project. WAKE IS \"stall\", NOT \"realtime\" OR \"none\": jcode has no Monitor, so a background task wakes it only when stdout goes quiet for stall_wake_seconds - and subscribe.mjs's only stdout writer is a real-message line, so by default the pipe is ALREADY silent while idle, making an unmodified stall wake fire on a timer regardless of whether anything arrived. `patchcord subscribe --stall-signal` (scripts/lib/stall-signal.mjs) inverts that for jcode specifically: writes a HEARTBEAT keepalive while idle so the pipe is never silently quiet by accident, and suppresses it on purpose after a real message so the stall condition elapses close to when the message actually arrived. This only works if stall_wake_seconds equals --stall-signal's own quiet window (15s by default) - a mismatch silently breaks the wake in one direction or the other, which is why the subscribe skill states the number explicitly rather than leaving it to be inferred. NOT VERIFIED END TO END: no jcode install on this machine to watch it actually fire."
236
236
  }
237
237
  }
238
238
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.43",
3
+ "version": "0.6.44",
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"
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: patchcord-inbox
3
+ description: Read Patchcord inbox and reply to messages
4
+ ---
5
+
6
+ ## Applies only when Patchcord MCP tools are loaded
7
+
8
+ If the `inbox` MCP tool is not available in this session, this skill does not
9
+ apply. Do nothing: do not run the Patchcord CLI, read a token from a config
10
+ file, or call the HTTP API. A missing project MCP config is a normal state.
11
+
12
+ Call the `inbox` MCP tool now. In its response, the first header line is YOUR
13
+ own identity (the recipient); the real sender of each message is on a
14
+ `From X` line — never confuse the two.
15
+
16
+ For each pending message, classify it and act:
17
+
18
+ - **ACK** — short signals like thanks, noted, works, great, ok, 👍 with no
19
+ task → close it silently: `reply(message_id, resolve=true)` with NO
20
+ content. Never send a text reply to an ack (it creates infinite ack
21
+ chains).
22
+ - **BLOCKED** — you cannot do the work right now (busy, missing credentials,
23
+ ambiguous target) → `reply(message_id, "<reason>", defer=true)` so it stays
24
+ in your inbox as a reminder. Never silently skip a message.
25
+ - **ACTIONABLE** — do the work the message asks for FIRST (edit the file,
26
+ run the command, write the code), THEN
27
+ `reply(message_id, "<concrete summary of what you did, with file paths and
28
+ line numbers>")`. Never reply "will do" / "understood" before doing the
29
+ work.
30
+
31
+ If the patchcord-subscribe listener is already running in the background,
32
+ you do not need to restart it after handling messages — unlike a
33
+ poll-and-exit script, it keeps running across wakes. Only restart it if you
34
+ have reason to believe it died (see the patchcord-subscribe skill).
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: patchcord-subscribe
3
+ description: >
4
+ Start the Patchcord background listener so new messages wake this agent.
5
+ Run this when the user asks to start Patchcord push delivery.
6
+ ---
7
+
8
+ jcode has no Monitor tool. A background task here wakes the agent on a
9
+ STALL — no new output for N seconds — not per line the way Claude Code's
10
+ Monitor does. Following Claude Code's own subscribe instructions verbatim on
11
+ jcode produces a listener that connects, receives, and never wakes you: the
12
+ pipe fills with messages you are never told about.
13
+
14
+ This skill exists to run correctly on jcode specifically, using
15
+ `patchcord subscribe --stall-signal`, a mode built for exactly this gap:
16
+ while idle it writes a `HEARTBEAT:` line often enough that the pipe never
17
+ looks silent, and on a real message it goes quiet on purpose for long enough
18
+ that jcode's own stall detector fires. This is this repo's global skill
19
+ directory (`~/.jcode/skills/`), shared by every jcode project on this
20
+ machine — nothing below may name a specific namespace, agent, or project
21
+ path.
22
+
23
+ # Start
24
+
25
+ 1. **Drain the inbox first.** Call the Patchcord `inbox` MCP tool. Process
26
+ every pending message (see the patchcord-inbox skill) before starting the
27
+ listener — a backlog can accumulate while no listener was running, and
28
+ the listener does not replay what happened before it started.
29
+
30
+ 2. **Spawn the listener with bash, in the background, with a stall wake:**
31
+
32
+ ```
33
+ Bash(
34
+ command: "patchcord subscribe --stall-signal | grep --line-buffered '^PATCHCORD:'; exit ${PIPESTATUS[0]}",
35
+ run_in_background: true,
36
+ stall_wake_seconds: 15
37
+ )
38
+ ```
39
+
40
+ `stall_wake_seconds: 15` is not a placeholder — it MUST match
41
+ `--stall-signal`'s own default quiet window (15000ms). If you ever pass
42
+ `--stall-signal` a custom triple (`keepaliveMs:quietMs:stallMs`),
43
+ `stall_wake_seconds` must equal the `stallMs` you passed, or the two
44
+ numbers disagree and the wake either fires on nothing (too short) or
45
+ never fires on a real message (too long). Do not use 30 here — that
46
+ number predates `--stall-signal` and is shorter than nothing: it is
47
+ simply the wrong number for this mechanism, not a safer one.
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.
53
+
54
+ 3. **Tell the user one line:** "Patchcord listener active — I'll check
55
+ regularly for new messages." Do not say "as messages arrive" — the
56
+ mechanism approximates that, it does not guarantee it, and promising more
57
+ than jcode can deliver is worse than being accurate.
58
+
59
+ # When the background task wakes you
60
+
61
+ A wake means the pipe went quiet for `stall_wake_seconds` — with
62
+ `--stall-signal` running, that quiet is now DESIGNED to correlate with a real
63
+ message, but two other things also produce exactly the same silence, and you
64
+ cannot tell which one happened from the wake alone:
65
+
66
+ 1. **Check the inbox every time.** Call the Patchcord `inbox` MCP tool. If it
67
+ has pending messages, handle them (see the patchcord-inbox skill). If it
68
+ is empty, say nothing to the user and do not report the wake as an event —
69
+ an empty check is not news.
70
+ 2. **If the inbox is empty, check whether the listener is still running**
71
+ before assuming this was a harmless false positive. A dead listener also
72
+ produces silence — that is a feature (it is how you notice), but only if
73
+ you actually look. If the background task has exited, restart it with the
74
+ command in Start, step 2.
75
+ 3. **Never read the last `PATCHCORD:` line in the task output as news.** It
76
+ is scrollback — output already displayed. It may be the same line you
77
+ already handled. The inbox call in step 1 is the source of truth; a
78
+ `PATCHCORD:` line without a corresponding inbox check is not confirmation
79
+ of anything.
80
+
81
+ # Stopping
82
+
83
+ Tell the user one of:
84
+
85
+ - End this session.
86
+ - Kill the listener: `kill $(cat /tmp/patchcord_subscribe_<namespace>_<agent>.pid)`
87
+ (the exact path is printed by `patchcord subscribe` on start; do not guess
88
+ the namespace/agent — this is a global skill and cannot know them).
89
+
90
+ # If the background task ends on its own
91
+
92
+ Read its output. Scan for one of:
93
+
94
+ - `no patchcord config found` — not run from a project with `.jcode/mcp.json`
95
+ (or a parent project directory).
96
+ - `ticket: token rejected (HTTP 401|403)` — the bearer token is invalid or
97
+ expired.
98
+ - `already running (pid N)` (exit 2) — another listener is active for this
99
+ agent; report it, do not respawn.
100
+ - `subscribe: fatal: ...` — report the fatal line verbatim.
101
+
102
+ If none of those appear, it likely ended with the session or because its
103
+ output consumer closed. Restart it only when the user asks to resume Patchcord
104
+ push delivery.
105
+
106
+ **Forbidden on failure:** no hand-rolled polling loop in place of this
107
+ mechanism, no pidfile editing, no re-arm loop after every wake — the
108
+ background task under `run_in_background` keeps running across wakes; you
109
+ are only checking in, not restarting it, unless step 2 above found it dead.
@@ -0,0 +1,26 @@
1
+ ---
2
+ name: patchcord-wait
3
+ description: Wait for one incoming Patchcord message
4
+ ---
5
+
6
+ ## Applies only when Patchcord MCP tools are loaded
7
+
8
+ If the `wait_for_message` MCP tool is not available in this session, this
9
+ skill does not apply. Do nothing: do not run the Patchcord CLI, read a token
10
+ from a config file, or call the HTTP API. A missing project MCP config is a
11
+ normal state.
12
+
13
+ Call the `wait_for_message` MCP tool now to block until a message arrives or
14
+ ~5 minutes elapse.
15
+
16
+ When a message arrives, classify it and act:
17
+
18
+ - **ACK** (thanks, noted, works, ok, 👍, no task) →
19
+ `reply(message_id, resolve=true)` with NO content. Never text-reply an ack.
20
+ - **BLOCKED** (cannot do the work right now) →
21
+ `reply(message_id, "<reason>", defer=true)`.
22
+ - **ACTIONABLE** → do the work FIRST, then
23
+ `reply(message_id, "<concrete summary of what you did>")`.
24
+
25
+ Use this skill for a single blocking wait, not as a substitute for the
26
+ persistent background listener — see the patchcord-subscribe skill for that.
@@ -0,0 +1,120 @@
1
+ // Resolve where Hermes's HOME actually is, honouring HERMES_HOME — and
2
+ // derive both config.yaml's path and the skills install destination from
3
+ // that ONE resolved home, never from separate hardcoded joins.
4
+ //
5
+ // WHY THIS EXISTS
6
+ //
7
+ // bin/patchcord.mjs hardcoded join(HOME, ".hermes", "config.yaml") in two
8
+ // places — the write site (writeWorkerConfig, provisioning a seat) and the
9
+ // read site (bearer resolution, what `patchcord whoami` verifies against).
10
+ // Neither read HERMES_HOME. mux sets HERMES_HOME=<seat workdir>/.hermes to
11
+ // isolate one Hermes identity per seat; without this, every seat installs
12
+ // into and reads from the SAME operator-global ~/.hermes/config.yaml.
13
+ //
14
+ // FIXING ONLY ONE SITE IS WORSE THAN FIXING NEITHER. `patchcord whoami` is
15
+ // mux's second independent witness that a seat holds the identity just
16
+ // minted for it. If the write moves to the seat's own HERMES_HOME but the
17
+ // read keeps resolving the operator's global config, whoami silently
18
+ // verifies the wrong file against itself — a check that always "passes"
19
+ // because it was never actually looking where the write went.
20
+ //
21
+ // SKILLS FOLLOW THE HOME TOO, same reasoning one level up: they are a
22
+ // GUARD, not documentation — carrying, among other rules, "do not run the
23
+ // patchcord CLI when MCP is loaded". A seat isolated by HERMES_HOME that
24
+ // still reads its skills from the operator's global
25
+ // ~/.hermes/skills/integrations/ is running under another context's guard,
26
+ // which is not isolation, just a config file that happens to be separate.
27
+ // bin/patchcord.mjs previously joined ~/.hermes/skills/integrations
28
+ // directly in three places, none aware of HERMES_HOME — the same defect
29
+ // class as the config.yaml sites, one directory over.
30
+ //
31
+ // RESOLUTION ORDER, identical everywhere a Hermes path is needed:
32
+ // 1. HERMES_HOME from the environment — this IS the home; nothing to
33
+ // derive.
34
+ // 2. `hermes config path`, if the binary resolves — the harness's OWN
35
+ // answer for where its config lives, not our model of it. This command
36
+ // itself honours HERMES_HOME, which is exactly why a caller that shells
37
+ // it must NOT strip the environment: execSync inherits process.env by
38
+ // default, and that default is load-bearing here — an explicit `env`
39
+ // override that omits HERMES_HOME would make this probe silently
40
+ // answer for the OPERATOR's home instead of the seat's, which looks
41
+ // identical to "the env var is being ignored" from the outside. The
42
+ // home is taken as dirname() of the printed config file path, on the
43
+ // same "config path" == ".../config.yaml" assumption documented below.
44
+ // 3. ~/.hermes — today's fallback, unchanged, so nothing existing breaks
45
+ // for an operator who never set HERMES_HOME.
46
+ //
47
+ // config.yaml's path and the skills destination are both joined onto this
48
+ // SAME resolved home (resolveHermesHome), never computed independently —
49
+ // the two must never be able to disagree about which home they mean.
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.
59
+ //
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.
73
+
74
+ import { execSync } from "node:child_process";
75
+ import { join, dirname } from "node:path";
76
+ import { homedir } from "node:os";
77
+
78
+ /** Shell `hermes config path`, inheriting the parent environment (the
79
+ * execSync default — do not pass an `env` override here). Returns the
80
+ * trimmed stdout, or null if the binary is absent, exits non-zero, or
81
+ * prints nothing. Exported so a test can substitute a fake without a real
82
+ * `hermes` on PATH. */
83
+ export function shellHermesConfigPath() {
84
+ try {
85
+ const out = execSync("hermes config path", {
86
+ stdio: ["ignore", "pipe", "ignore"],
87
+ }).toString("utf-8").trim();
88
+ return out || null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /** Resolve Hermes's home DIRECTORY per the order documented above.
95
+ * `home` and `shell` are injectable for tests; production callers use the
96
+ * defaults (real homedir(), real shellHermesConfigPath()). Every other
97
+ * Hermes path in this module is joined onto this one value. */
98
+ export function resolveHermesHome({ home = homedir(), shell = shellHermesConfigPath } = {}) {
99
+ const envHome = process.env.HERMES_HOME;
100
+ if (envHome) {
101
+ return envHome;
102
+ }
103
+ const shelledConfigPath = shell();
104
+ if (shelledConfigPath) {
105
+ return dirname(shelledConfigPath);
106
+ }
107
+ return join(home, ".hermes");
108
+ }
109
+
110
+ /** Resolve the Hermes config.yaml path. */
111
+ export function resolveHermesConfigPath(opts) {
112
+ return join(resolveHermesHome(opts), "config.yaml");
113
+ }
114
+
115
+ /** Resolve where Hermes skills install — same home as config.yaml, so a
116
+ * seat's skills and its identity can never point at different machines'
117
+ * worth of Hermes. */
118
+ export function resolveHermesSkillsDest(opts) {
119
+ return join(resolveHermesHome(opts), "skills", "integrations");
120
+ }
@@ -0,0 +1,104 @@
1
+ // Pure parsing + validation for `patchcord subscribe --stall-signal[=<opts>]`.
2
+ //
3
+ // WHY THIS MODE EXISTS
4
+ //
5
+ // subscribe.mjs's only stdout writer is notify() — it writes a "PATCHCORD: ..."
6
+ // line on a real message and nothing else. There is no periodic heartbeat line;
7
+ // the WebSocket-level ping (HEARTBEAT_INTERVAL_MS in subscribe.mjs) never
8
+ // touches stdout. So on a harness that wakes the agent on a STALL (no stdout
9
+ // for N seconds) instead of per-line like Claude Code's Monitor, the pipe is
10
+ // ALREADY silent while idle — a stall fires whether or not a message arrived,
11
+ // which is a wake-on-silence mechanism, not wake-on-message.
12
+ //
13
+ // --stall-signal inverts that: while idle it writes a keepalive line often
14
+ // enough that the pipe never looks silent, and on a real message it goes
15
+ // quiet on purpose for long enough that the harness's stall detector fires.
16
+ // The result approximates wake-on-message using only a wake-on-silence
17
+ // primitive.
18
+ //
19
+ // KEPT SEPARATE FROM subscribe.mjs so the arithmetic can be unit tested
20
+ // without opening a WebSocket, resolving a project config, or touching a
21
+ // pidfile — all things subscribe.mjs does as soon as it runs.
22
+ //
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.
26
+
27
+ export const STALL_SIGNAL_DEFAULTS = Object.freeze({
28
+ // Written to stdout on this interval while idle. Must stay well under
29
+ // stallMs or the pipe can look idle for a full stall window even though
30
+ // nothing is wrong — see validateStallSignalOpts.
31
+ keepaliveMs: 5000,
32
+ // Keepalives are suppressed for this long after a real notification, so
33
+ // the pipe goes quiet on purpose. Must be >= stallMs or the harness's
34
+ // stall condition never actually triggers and the message is missed.
35
+ quietMs: 15000,
36
+ // What the CALLER promises the harness's own stall/wake threshold is set
37
+ // to (e.g. jcode's `stall_wake_seconds`, in ms here). subscribe.mjs cannot
38
+ // observe that value — the harness owns it — so this is supplied by
39
+ // whoever launches --stall-signal and used only to validate the other two
40
+ // against it before this process does anything live.
41
+ stallMs: 15000,
42
+ });
43
+
44
+ /** Parse `--stall-signal` out of an argv array. Returns null if the flag is
45
+ * absent (mode disabled, nothing else in this module runs) or a resolved
46
+ * options object. Throws on a malformed or nonsensical inline value —
47
+ * the caller decides how to report that (subscribe.mjs calls its own
48
+ * die()). Absence of the flag is untouched by this function: it returns
49
+ * null and every default in the rest of subscribe.mjs is unaffected. */
50
+ export function parseStallSignalArg(argv) {
51
+ if (!argv.includes("--stall-signal")) return null;
52
+ const i = argv.indexOf("--stall-signal");
53
+ const inline = i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[i + 1] : null;
54
+ return resolveStallSignalOpts(inline);
55
+ }
56
+
57
+ /** `inline` is `keepaliveMs:quietMs:stallMs`, any suffix omitted to take the
58
+ * default (so `--stall-signal` alone, `--stall-signal 8000`, and
59
+ * `--stall-signal 8000:20000:20000` are all legal). Throws on a
60
+ * non-positive/non-numeric field or a combination validateStallSignalOpts
61
+ * rejects. */
62
+ export function resolveStallSignalOpts(inline) {
63
+ const d = STALL_SIGNAL_DEFAULTS;
64
+ if (!inline) {
65
+ const opts = { ...d };
66
+ validateStallSignalOpts(opts);
67
+ return opts;
68
+ }
69
+ const parts = inline.split(":");
70
+ const pick = (raw, fallback, label) => {
71
+ if (raw === undefined || raw === "") return fallback;
72
+ const n = Number(raw);
73
+ if (!Number.isFinite(n) || n <= 0) {
74
+ throw new Error(`--stall-signal: ${label} must be a positive number of milliseconds, got "${raw}"`);
75
+ }
76
+ return n;
77
+ };
78
+ const opts = {
79
+ keepaliveMs: pick(parts[0], d.keepaliveMs, "keepaliveMs"),
80
+ quietMs: pick(parts[1], d.quietMs, "quietMs"),
81
+ stallMs: pick(parts[2], d.stallMs, "stallMs"),
82
+ };
83
+ validateStallSignalOpts(opts);
84
+ return opts;
85
+ }
86
+
87
+ /** Refuse a nonsensical pair rather than run silently wrong. Both directions
88
+ * matter: a keepalive too close to stallMs risks a false stall while idle,
89
+ * and a quiet window shorter than stallMs means the harness's stall NEVER
90
+ * fires on a real message — the exact failure this mode exists to prevent. */
91
+ export function validateStallSignalOpts(opts) {
92
+ if (opts.keepaliveMs >= opts.stallMs) {
93
+ throw new Error(
94
+ `--stall-signal: keepaliveMs (${opts.keepaliveMs}) must be less than stallMs (${opts.stallMs}) — ` +
95
+ `otherwise the pipe can go quiet for a full stall window while idle and nothing is wrong`
96
+ );
97
+ }
98
+ if (opts.quietMs < opts.stallMs) {
99
+ throw new Error(
100
+ `--stall-signal: quietMs (${opts.quietMs}) must be at least stallMs (${opts.stallMs}) — ` +
101
+ `otherwise the harness's stall window never elapses during the quiet period and a real message is missed`
102
+ );
103
+ }
104
+ }
@@ -14,6 +14,7 @@ import { URL } from "node:url";
14
14
  import { dirname } from "node:path";
15
15
  import { connect as wsConnect } from "./lib/ws.mjs";
16
16
  import { resolveProjectBearer, listProjectBearers } from "./lib/resolve-project-bearer.mjs";
17
+ import { parseStallSignalArg } from "./lib/stall-signal.mjs";
17
18
 
18
19
  // --- Hermes webhook bridge mode -------------------------------------------
19
20
  // Default mode writes "PATCHCORD: ..." lines to stdout for Claude Code's
@@ -30,6 +31,36 @@ const HERMES_WEBHOOK = (() => {
30
31
  return process.env.PATCHCORD_HERMES_WEBHOOK || inline || null;
31
32
  })();
32
33
 
34
+ // --- Stall-signal mode ------------------------------------------------------
35
+ // Default mode's only stdout writer is notify() — nothing else touches
36
+ // stdout, so a harness with no Monitor that wakes on a STALL (no output for
37
+ // N seconds) instead of per-line wakes on a timer, not on a message: the pipe
38
+ // is silent whether or not anything arrived. See scripts/lib/stall-signal.mjs
39
+ // for the inversion this mode performs (keepalive while idle, deliberate
40
+ // quiet on a real message) and why the two windows must relate the way they
41
+ // do. `--stall-signal` alone uses every default; `--stall-signal
42
+ // keepaliveMs:quietMs:stallMs` overrides any prefix of the three. A malformed
43
+ // or self-contradictory value is a startup error, not a silently-wrong run.
44
+ let STALL_SIGNAL_OPTS = null;
45
+ try {
46
+ STALL_SIGNAL_OPTS = parseStallSignalArg(process.argv);
47
+ } catch (e) {
48
+ die(e.message);
49
+ }
50
+ const STALL_SIGNAL_MODE = STALL_SIGNAL_OPTS !== null;
51
+ if (STALL_SIGNAL_MODE && HERMES_MODE) {
52
+ die("--stall-signal and --hermes are mutually exclusive — each is its own wake mechanism, pick one");
53
+ }
54
+ // The keepalive line's own prefix, chosen so it can never collide with a
55
+ // real notification: every subscribe skill's grep is `^PATCHCORD:`, and
56
+ // "HEARTBEAT:" does not start with "PATCHCORD" at all, so nothing else needs
57
+ // to change for a consumer that already filters on that pattern.
58
+ const STALL_SIGNAL_KEEPALIVE_PREFIX = "HEARTBEAT:";
59
+ // Suppress keepalives until this timestamp (ms since epoch). 0 = never
60
+ // suppressed. Set by notify() on every real message.
61
+ let stallSignalQuietUntil = 0;
62
+ let stallSignalTimer = null;
63
+
33
64
  const JWT_REFRESH_SAFETY_MARGIN_SEC = 120;
34
65
  const HEARTBEAT_INTERVAL_MS = 25_000;
35
66
  const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 15_000, 30_000];
@@ -171,6 +202,11 @@ function httpJson(urlStr, { method = "GET", headers = {}, body = null } = {}) {
171
202
  async function notify(line, meta = {}) {
172
203
  if (!HERMES_MODE) {
173
204
  process.stdout.write(line + "\n");
205
+ // Go quiet on purpose: suppress keepalives long enough that the
206
+ // harness's own stall detector actually elapses during this window.
207
+ if (STALL_SIGNAL_MODE) {
208
+ stallSignalQuietUntil = Date.now() + STALL_SIGNAL_OPTS.quietMs;
209
+ }
174
210
  return;
175
211
  }
176
212
  if (!HERMES_WEBHOOK) return;
@@ -330,7 +366,10 @@ async function run() {
330
366
  const pidfile = `/tmp/patchcord_subscribe_${ticket.namespace_ids[0]}_${ticket.agent_id}.pid`;
331
367
  writePidfile(pidfile);
332
368
 
333
- const cleanup = () => removePidfile(pidfile);
369
+ const cleanup = () => {
370
+ removePidfile(pidfile);
371
+ if (stallSignalTimer) clearInterval(stallSignalTimer);
372
+ };
334
373
  process.on("exit", cleanup);
335
374
  process.on("SIGINT", () => {
336
375
  cleanup();
@@ -366,6 +405,23 @@ async function run() {
366
405
 
367
406
  logErr(`subscribe: agent=${ticket.agent_id} namespaces=${ticket.namespace_ids.join(",")}`);
368
407
 
408
+ // Runs for the process's whole life, independent of WS connect/reconnect
409
+ // state — a reconnect backoff is a normal transient state, not something
410
+ // that should look like a stall to the harness. Suppressed for quietMs
411
+ // after a real notification (notify() sets stallSignalQuietUntil); resumes
412
+ // once that window elapses.
413
+ if (STALL_SIGNAL_MODE) {
414
+ logErr(
415
+ `subscribe: stall-signal keepalive=${STALL_SIGNAL_OPTS.keepaliveMs}ms ` +
416
+ `quiet=${STALL_SIGNAL_OPTS.quietMs}ms stall=${STALL_SIGNAL_OPTS.stallMs}ms`
417
+ );
418
+ stallSignalTimer = setInterval(() => {
419
+ if (Date.now() >= stallSignalQuietUntil) {
420
+ process.stdout.write(`${STALL_SIGNAL_KEEPALIVE_PREFIX} ${new Date().toISOString()}\n`);
421
+ }
422
+ }, STALL_SIGNAL_OPTS.keepaliveMs);
423
+ }
424
+
369
425
  let backoffIdx = 0;
370
426
 
371
427
  const refreshAuth = async () => {
@@ -30,6 +30,48 @@ User invoked /patchcord:subscribe — do NOT substitute `wait_for_message()`. Sp
30
30
 
31
31
  3. **Tell the user one line:** *"Patchcord listener active — I'll pick up new messages as they arrive."*
32
32
 
33
+ # IF YOUR HARNESS HAS NO `Monitor` TOOL — READ THIS BEFORE STEP 2
34
+
35
+ `Monitor` is a Claude Code tool. Step 2 above assumes it, and the assumption is
36
+ load-bearing rather than stylistic: Monitor turns EVERY stdout line into a
37
+ notification, which is what makes a message arriving wake you.
38
+
39
+ Other harnesses run a background command instead (`bash` with
40
+ `run_in_background: true`, or your harness's equivalent), and those typically
41
+ notify on a STALL — no output for N seconds — not on each line. On such a
42
+ harness, following step 2 verbatim produces a listener that WORKS and NEVER
43
+ WAKES YOU: the pipe fills with messages you are never told about, and you
44
+ discover them only when the user asks why you did not answer. That has happened
45
+ in production, on jcode, and is the reason this section exists.
46
+
47
+ So, if you have no `Monitor`:
48
+
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.
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".
59
+
60
+ 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.
74
+
33
75
  # When a notification fires
34
76
 
35
77
  Monitor surfaces `PATCHCORD: 1 new from <sender>`: