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.
- 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 +42 -0
- package/bin/patchcord.mjs +86 -18
- package/harnesses.json +8 -8
- package/package.json +1 -1
- package/per-project-skills/jcode/inbox/SKILL.md +34 -0
- package/per-project-skills/jcode/subscribe/SKILL.md +109 -0
- package/per-project-skills/jcode/wait/SKILL.md +26 -0
- package/scripts/lib/hermes-home.mjs +120 -0
- package/scripts/lib/stall-signal.mjs +104 -0
- package/scripts/subscribe.mjs +57 -1
- package/skills/subscribe/SKILL.md +42 -0
package/agent-plugin/plugin.json
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
// worker
|
|
1584
|
-
//
|
|
1585
|
-
|
|
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 =
|
|
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).
|
|
2993
|
-
//
|
|
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 =
|
|
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 (
|
|
3767
|
-
|
|
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
|
|
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 =
|
|
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": "
|
|
133
|
-
"installer_config": "~/.hermes/config.yaml",
|
|
134
|
-
"harness_scope": "per-profile",
|
|
135
|
-
"installer_defect": "
|
|
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": "
|
|
231
|
-
"mechanism":
|
|
232
|
-
"self_arm":
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/scripts/subscribe.mjs
CHANGED
|
@@ -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 = () =>
|
|
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>`:
|