patchcord 0.5.91 → 0.5.94

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/bin/patchcord.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, cpSync, readdirSync } from "fs";
3
+ import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync } from "fs";
4
4
  import { join, dirname, basename } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { execSync } from "child_process";
@@ -107,6 +107,64 @@ function _kimiContextRequested(options = {}) {
107
107
  || Boolean(process.env.KIMI_MCP_CONFIG_FILE);
108
108
  }
109
109
 
110
+ // Insert-or-update mcp_servers.patchcord in a Hermes ~/.hermes/config.yaml,
111
+ // preserving the rest of the user's YAML. Block-style only (what Hermes writes).
112
+ function upsertHermesConfig(existing, url, token) {
113
+ const block = [
114
+ " patchcord:",
115
+ ` url: "${url}"`,
116
+ " headers:",
117
+ ` Authorization: "Bearer ${token}"`,
118
+ ];
119
+ if (!existing.trim()) return `mcp_servers:\n${block.join("\n")}\n`;
120
+ const lines = existing.split(/\r?\n/);
121
+ const mcpIdx = lines.findIndex((l) => /^mcp_servers:\s*$/.test(l));
122
+ if (mcpIdx === -1) {
123
+ const sep = existing.endsWith("\n") ? "" : "\n";
124
+ return existing + sep + `\nmcp_servers:\n${block.join("\n")}\n`;
125
+ }
126
+ let end = lines.length;
127
+ for (let i = mcpIdx + 1; i < lines.length; i++) {
128
+ if (lines[i].trim() === "") continue;
129
+ if (/^\S/.test(lines[i])) { end = i; break; }
130
+ }
131
+ let pcStart = -1;
132
+ for (let i = mcpIdx + 1; i < end; i++) {
133
+ if (/^\s+patchcord:\s*$/.test(lines[i])) { pcStart = i; break; }
134
+ }
135
+ if (pcStart !== -1) {
136
+ const pcIndent = lines[pcStart].match(/^(\s*)/)[1].length;
137
+ let pcEnd = end;
138
+ for (let i = pcStart + 1; i < end; i++) {
139
+ if (lines[i].trim() === "") continue;
140
+ if (lines[i].match(/^(\s*)/)[1].length <= pcIndent) { pcEnd = i; break; }
141
+ }
142
+ return [...lines.slice(0, pcStart), ...block, ...lines.slice(pcEnd)].join("\n").replace(/\n*$/, "\n");
143
+ }
144
+ return [...lines.slice(0, mcpIdx + 1), ...block, ...lines.slice(mcpIdx + 1)].join("\n").replace(/\n*$/, "\n");
145
+ }
146
+
147
+ // Read url + bearer token from a Hermes config.yaml's mcp_servers.patchcord.
148
+ function readHermesShape(path) {
149
+ if (!existsSync(path)) return null;
150
+ let text;
151
+ try { text = readFileSync(path, "utf-8"); } catch { return null; }
152
+ const lines = text.split(/\r?\n/);
153
+ const pc = lines.findIndex((l) => /^\s{2}patchcord:\s*$/.test(l));
154
+ if (pc === -1) return null;
155
+ let url = null, token = null;
156
+ for (let i = pc + 1; i < lines.length; i++) {
157
+ if (lines[i].trim() === "") continue;
158
+ if (lines[i].match(/^(\s*)/)[1].length <= 2) break;
159
+ const um = lines[i].match(/url:\s*"?([^"\n]+?)"?\s*$/);
160
+ if (um && !url) url = um[1].trim();
161
+ const am = lines[i].match(/Authorization:\s*"?Bearer\s+([^"\n]+?)"?\s*$/);
162
+ if (am && !token) token = am[1].trim();
163
+ }
164
+ if (!url || !token) return null;
165
+ return { token, baseUrl: url.replace(/\/mcp(\/bearer)?$/, ""), configFile: path, tool: "hermes" };
166
+ }
167
+
110
168
  async function _resolveBearer(options = {}) {
111
169
  const { readFileSync } = await import("fs");
112
170
  const preferKimi = _kimiContextRequested(options);
@@ -200,6 +258,7 @@ async function _resolveBearer(options = {}) {
200
258
 
201
259
  // Per-project (walk up from cwd). First win.
202
260
  const kimiProjectReader = (cwd) => readJsonAt(join(cwd, ".kimi", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
261
+ const kimiCodeProjectReader = (cwd) => readJsonAt(join(cwd, ".kimi-code", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
203
262
  const defaultProjectReaders = [
204
263
  (cwd) => readJsonAt(join(cwd, ".mcp.json"), ["mcpServers", "patchcord"], "claude_code"),
205
264
  (cwd) => readJsonAt(join(cwd, ".cursor", "mcp.json"), ["mcpServers", "patchcord"], "cursor"),
@@ -208,8 +267,8 @@ async function _resolveBearer(options = {}) {
208
267
  (cwd) => readCodexTomlShape(join(cwd, ".codex", "config.toml")),
209
268
  ];
210
269
  const projectReaders = preferKimi
211
- ? [kimiProjectReader, ...defaultProjectReaders]
212
- : [...defaultProjectReaders, kimiProjectReader];
270
+ ? [kimiProjectReader, kimiCodeProjectReader, ...defaultProjectReaders]
271
+ : [...defaultProjectReaders, kimiProjectReader, kimiCodeProjectReader];
213
272
  let dir = process.cwd();
214
273
  while (dir && dir !== "/") {
215
274
  for (const r of projectReaders) {
@@ -249,17 +308,19 @@ async function _resolveBearer(options = {}) {
249
308
  })();
250
309
 
251
310
  const kimiGlobalReader = () => readJsonAt(join(HOME, ".kimi", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
311
+ const kimiCodeGlobalReader = () => readJsonAt(join(process.env.KIMI_CODE_HOME || join(HOME, ".kimi-code"), "mcp.json"), ["mcpServers", "patchcord"], "kimi");
252
312
  const defaultGlobalCandidates = [
253
313
  () => readJsonAt(join(HOME, ".codeium", "windsurf", "mcp_config.json"), ["mcpServers", "patchcord"], "windsurf"),
254
314
  () => readJsonAt(join(HOME, ".gemini", "settings.json"), ["mcpServers", "patchcord"], "gemini"),
255
315
  () => readJsonAt(zedPath, ["context_servers", "patchcord"], "zed"),
256
316
  () => readJsonAt(join(HOME, ".openclaw", "openclaw.json"), ["mcp", "servers", "patchcord"], "openclaw"),
257
317
  () => readJsonAt(join(HOME, ".gemini", "antigravity", "mcp_config.json"), ["mcpServers", "patchcord"], "antigravity"),
318
+ () => readHermesShape(join(HOME, ".hermes", "config.yaml")),
258
319
  ...clinePaths.map((p) => () => readJsonAt(p, ["mcpServers", "patchcord"], "cline")),
259
320
  ];
260
321
  const globalCandidates = preferKimi
261
- ? [kimiGlobalReader, ...defaultGlobalCandidates]
262
- : [...defaultGlobalCandidates, kimiGlobalReader];
322
+ ? [kimiGlobalReader, kimiCodeGlobalReader, ...defaultGlobalCandidates]
323
+ : [...defaultGlobalCandidates, kimiGlobalReader, kimiCodeGlobalReader];
263
324
  for (const r of globalCandidates) {
264
325
  const found = r();
265
326
  if (found) return found;
@@ -1225,7 +1286,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1225
1286
  const CLIENT_TYPE_MAP = {
1226
1287
  "claude_code": "1", "codex": "2", "cursor": "3", "windsurf": "4",
1227
1288
  "gemini": "5", "vscode": "6", "zed": "7", "opencode": "8", "openclaw": "9", "antigravity": "10",
1228
- "cline": "11", "kimi": "12",
1289
+ "cline": "11", "kimi": "12", "hermes": "13",
1229
1290
  };
1230
1291
 
1231
1292
 
@@ -1280,9 +1341,10 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1280
1341
  console.log(` ${cyan}1.${r} Claude Code ${cyan}5.${r} Gemini CLI ${cyan}9.${r} OpenClaw`);
1281
1342
  console.log(` ${cyan}2.${r} Codex CLI ${cyan}6.${r} VS Code ${cyan}10.${r} Antigravity`);
1282
1343
  console.log(` ${cyan}3.${r} Cursor ${cyan}7.${r} Zed ${cyan}11.${r} Cline`);
1283
- console.log(` ${cyan}4.${r} Windsurf ${cyan}8.${r} OpenCode ${cyan}12.${r} Kimi CLI\n`);
1284
- choice = (await ask(`${dim}Choose (1-12):${r} `)).trim();
1285
- if (!["1","2","3","4","5","6","7","8","9","10","11","12"].includes(choice)) {
1344
+ console.log(` ${cyan}4.${r} Windsurf ${cyan}8.${r} OpenCode ${cyan}12.${r} Kimi CLI`);
1345
+ console.log(` ${cyan}13.${r} Hermes\n`);
1346
+ choice = (await ask(`${dim}Choose (1-13):${r} `)).trim();
1347
+ if (!["1","2","3","4","5","6","7","8","9","10","11","12","13"].includes(choice)) {
1286
1348
  console.error("Invalid choice.");
1287
1349
  rl.close();
1288
1350
  process.exit(1);
@@ -1607,6 +1669,23 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1607
1669
  const isAntigravity = choice === "10";
1608
1670
  const isCline = choice === "11";
1609
1671
  const isKimi = choice === "12";
1672
+ const isHermes = choice === "13";
1673
+
1674
+ // MoonshotAI ships TWO CLIs that both use the `kimi` command:
1675
+ // • kimi-cli (Python): supports `--mcp-config-file`, config in .kimi/mcp.json
1676
+ // • Kimi Code (Node): no such flag — auto-merges project-local .kimi-code/mcp.json
1677
+ // Detect Kimi Code by its home dir, or by `kimi --help` lacking the old flag.
1678
+ const kimiCodeHome = process.env.KIMI_CODE_HOME || join(HOME, ".kimi-code");
1679
+ let isKimiCode = false;
1680
+ if (isKimi) {
1681
+ let kimiHelp = "";
1682
+ try {
1683
+ kimiHelp = execSync("kimi --help 2>&1", { encoding: "utf-8", timeout: 4000, stdio: "pipe" });
1684
+ } catch (e) {
1685
+ kimiHelp = e && e.stdout ? String(e.stdout) : "";
1686
+ }
1687
+ isKimiCode = existsSync(kimiCodeHome) || (kimiHelp.length > 0 && !/mcp-config-file/.test(kimiHelp));
1688
+ }
1610
1689
 
1611
1690
  const hostname = run("hostname -s") || run("hostname") || "unknown";
1612
1691
 
@@ -1635,6 +1714,29 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1635
1714
  console.log(`\n ${green}✓${r} Cursor configured: ${dim}${cursorPath}${r}`);
1636
1715
  console.log(` ${dim}Per-project only — other projects won't see this agent.${r}`);
1637
1716
  }
1717
+ } else if (isHermes) {
1718
+ // Hermes: global only (~/.hermes/config.yaml, YAML, mcp_servers key)
1719
+ const hermesPath = join(HOME, ".hermes", "config.yaml");
1720
+ mkdirSync(dirname(hermesPath), { recursive: true });
1721
+ let existingYaml = "";
1722
+ try { existingYaml = existsSync(hermesPath) ? readFileSync(hermesPath, "utf-8") : ""; } catch {}
1723
+ try {
1724
+ writeFileSync(hermesPath, upsertHermesConfig(existingYaml, `${serverUrl}/mcp`, token));
1725
+ console.log(`\n ${green}✓${r} Hermes configured: ${dim}${hermesPath}${r}`);
1726
+ console.log(` ${dim}Run ${r}${bold}/reload-mcp${r}${dim} in Hermes to pick it up.${r}`);
1727
+ } catch (e) {
1728
+ console.log(`\n ${yellow}⚠ Failed to write ${hermesPath}: ${e.message}${r}`);
1729
+ }
1730
+ // Install Hermes skills to ~/.hermes/skills/integrations/
1731
+ try {
1732
+ const hermesSkillsSrc = join(pluginRoot, "per-project-skills", "hermes");
1733
+ if (existsSync(hermesSkillsSrc)) {
1734
+ const hermesSkillsDest = join(HOME, ".hermes", "skills", "integrations");
1735
+ mkdirSync(hermesSkillsDest, { recursive: true });
1736
+ cpSync(hermesSkillsSrc, hermesSkillsDest, { recursive: true });
1737
+ console.log(` ${green}✓${r} Hermes skills installed: ${dim}${hermesSkillsDest}${r}`);
1738
+ }
1739
+ } catch {}
1638
1740
  } else if (isWindsurf) {
1639
1741
  // Windsurf: global only (~/.codeium/windsurf/mcp_config.json)
1640
1742
  const wsPath = join(HOME, ".codeium", "windsurf", "mcp_config.json");
@@ -1839,8 +1941,36 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1839
1941
  console.log(`\n ${green}✓${r} Cline configured: ${dim}${clinePath}${r}`);
1840
1942
  console.log(` ${yellow}Global config — all Cline projects share this agent.${r}`);
1841
1943
  }
1944
+ } else if (isKimi && isKimiCode) {
1945
+ // Kimi Code (Node/TS): project-local .kimi-code/mcp.json is auto-merged on
1946
+ // startup — no --mcp-config-file flag and no wrapper. Just run `kimi`.
1947
+ const kcDir = join(cwd, ".kimi-code");
1948
+ const kcPath = join(kcDir, "mcp.json");
1949
+ const kcOk = updateJsonConfig(kcPath, (obj) => {
1950
+ obj.mcpServers = obj.mcpServers || {};
1951
+ obj.mcpServers.patchcord = {
1952
+ url: `${serverUrl}/mcp`,
1953
+ headers: {
1954
+ Authorization: `Bearer ${token}`,
1955
+ "X-Patchcord-Machine": hostname,
1956
+ },
1957
+ };
1958
+ });
1959
+ if (kcOk) {
1960
+ console.log(`\n ${green}✓${r} Kimi Code configured: ${dim}${kcPath}${r}`);
1961
+ console.log(` ${dim}Kimi Code auto-loads project-local .kimi-code/mcp.json — just run ${r}${bold}kimi${r}${dim} in this project (no wrapper needed).${r}`);
1962
+ }
1963
+ // Skills → user-level Kimi Code skills dir
1964
+ try {
1965
+ for (const [label, rel] of [["patchcord:inbox", "inbox"], ["patchcord:wait", "wait"], ["patchcord:subscribe", "subscribe"]]) {
1966
+ const dest = join(kimiCodeHome, "skills", label);
1967
+ mkdirSync(dest, { recursive: true });
1968
+ cpSync(join(pluginRoot, "per-project-skills", "kimi", rel, "SKILL.md"), join(dest, "SKILL.md"));
1969
+ }
1970
+ console.log(` ${green}✓${r} Skills installed: ${dim}${join(kimiCodeHome, "skills")}${r}`);
1971
+ } catch {}
1842
1972
  } else if (isKimi) {
1843
- // Kimi CLI: per-project .kimi/mcp.json + shell wrapper for --mcp-config-file
1973
+ // Kimi CLI (Python): per-project .kimi/mcp.json + shell wrapper for --mcp-config-file
1844
1974
  const kimiDir = join(cwd, ".kimi");
1845
1975
  const kimiPath = join(kimiDir, "mcp.json");
1846
1976
  const kimiOk = updateJsonConfig(kimiPath, (obj) => {
@@ -2140,34 +2270,49 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
2140
2270
  }
2141
2271
  }
2142
2272
 
2143
- // Warn about gitignore for per-project configs with tokens
2144
- if (!isWindsurf && !isGemini && !isZed && !isOpenClaw && !isAntigravity && !isCline && !isKimi) {
2273
+ // Auto-add per-project configs with tokens to .gitignore (don't just warn —
2274
+ // a committed token gets clobbered/reverted by git and silently breaks auth).
2275
+ // Hermes is global config (~/.hermes/config.yaml) — no per-project file to ignore.
2276
+ if (!isWindsurf && !isGemini && !isZed && !isOpenClaw && !isAntigravity && !isCline && !isHermes) {
2145
2277
  const gitignorePath = join(cwd, ".gitignore");
2146
- const configFile = isCodex ? ".codex/config.toml" : isCursor ? ".cursor/mcp.json" : isVSCode ? ".vscode/mcp.json" : isOpenCode ? "opencode.json" : ".mcp.json";
2147
- let needsWarning = true;
2148
- if (existsSync(gitignorePath)) {
2149
- const gi = readFileSync(gitignorePath, "utf-8");
2150
- // Only check patterns relevant to this agent's config file
2151
- const patterns = [configFile];
2152
- if (isCodex) patterns.push(".codex/");
2153
- else if (isCursor) patterns.push(".cursor/");
2154
- else if (isVSCode) patterns.push(".vscode/");
2155
- if (patterns.some(p => gi.includes(p))) needsWarning = false;
2156
- }
2157
- if (needsWarning) {
2158
- console.log(`\n ${yellow}⚠ Add ${configFile} to .gitignore it contains your token${r}`);
2278
+ const configFile = isKimiCode ? ".kimi-code/mcp.json" : isKimi ? ".kimi/mcp.json" : isCodex ? ".codex/config.toml" : isCursor ? ".cursor/mcp.json" : isVSCode ? ".vscode/mcp.json" : isOpenCode ? "opencode.json" : ".mcp.json";
2279
+ // Forms that already cover this config (its file or its dir)
2280
+ const patterns = [configFile];
2281
+ if (isKimiCode) patterns.push(".kimi-code/");
2282
+ else if (isKimi) patterns.push(".kimi/");
2283
+ else if (isCodex) patterns.push(".codex/");
2284
+ else if (isCursor) patterns.push(".cursor/");
2285
+ else if (isVSCode) patterns.push(".vscode/");
2286
+
2287
+ const hasGitignore = existsSync(gitignorePath);
2288
+ // Only touch .gitignore inside a real git repo, or where one already exists.
2289
+ if (hasGitignore || existsSync(join(cwd, ".git"))) {
2290
+ const gi = hasGitignore ? readFileSync(gitignorePath, "utf-8") : "";
2291
+ const alreadyIgnored = patterns.some(p => gi.includes(p));
2292
+ if (!alreadyIgnored) {
2293
+ const prefix = gi.length && !gi.endsWith("\n") ? "\n" : "";
2294
+ const block = `${prefix}\n# patchcord — holds your auth token, never commit\n${configFile}\n`;
2295
+ try {
2296
+ writeFileSync(gitignorePath, gi + block);
2297
+ console.log(`\n ${green}✓${r} Added ${bold}${configFile}${r} to .gitignore ${dim}(it holds your token)${r}`);
2298
+ } catch {
2299
+ console.log(`\n ${yellow}⚠ Add ${configFile} to .gitignore — it contains your token${r}`);
2300
+ }
2301
+ }
2159
2302
  }
2160
2303
  }
2161
2304
 
2162
- const toolName = isKimi ? "Kimi CLI" : isAntigravity ? "Antigravity" : isCline ? "Cline" : isOpenClaw ? "OpenClaw" : isOpenCode ? "OpenCode" : isZed ? "Zed" : isVSCode ? "VS Code" : isGemini ? "Gemini CLI" : isWindsurf ? "Windsurf" : isCursor ? "Cursor" : isCodex ? "Codex" : "Claude Code";
2305
+ const toolName = isHermes ? "Hermes" : isKimiCode ? "Kimi Code" : isKimi ? "Kimi CLI" : isAntigravity ? "Antigravity" : isCline ? "Cline" : isOpenClaw ? "OpenClaw" : isOpenCode ? "OpenCode" : isZed ? "Zed" : isVSCode ? "VS Code" : isGemini ? "Gemini CLI" : isWindsurf ? "Windsurf" : isCursor ? "Cursor" : isCodex ? "Codex" : "Claude Code";
2163
2306
 
2164
- if (!isWindsurf && !isGemini && !isZed && !isOpenClaw && !isAntigravity && !isCline && !isKimi) {
2307
+ if (!isWindsurf && !isGemini && !isZed && !isOpenClaw && !isAntigravity && !isCline && !isKimi && !isHermes) {
2165
2308
  console.log(`\n ${dim}To connect a second agent:${r}`);
2166
2309
  console.log(` ${dim}cd into another project and run${r} ${bold}npx patchcord@latest${r} ${dim}there.${r}`);
2167
2310
  }
2168
2311
 
2169
2312
  if (isOpenClaw) {
2170
2313
  console.log(`\n${dim}Run${r} ${bold}openclaw gateway restart${r}${dim}, then tools will be available in your channels.${r}`);
2314
+ } else if (isKimiCode) {
2315
+ console.log(`\n ${green}→${r} ${bold}Restart ${cyan}kimi${r}${bold} in this project (or run ${cyan}/reload${r}${bold}), then say: ${cyan}${bold}check inbox${r}`);
2171
2316
  } else if (isKimi) {
2172
2317
  console.log(`\n ${green}→${r} ${bold}Restart your ${toolName} session with ${cyan}kimi-pc${r}${bold}, then say: ${cyan}${bold}check inbox${r}`);
2173
2318
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.5.91",
3
+ "version": "0.5.94",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: patchcord-inbox
3
+ description: Check the Patchcord inbox and reply to messages from other agents. Use when the user mentions patchcord, inbox, other agents, or a Patchcord webhook fired about new messages.
4
+ version: 1.0.0
5
+ metadata:
6
+ hermes:
7
+ tags: [patchcord, messaging, agents, mcp]
8
+ category: integrations
9
+ ---
10
+ # Patchcord — inbox
11
+
12
+ ## When to use
13
+ The user mentions patchcord / other agents / checking messages, or a Patchcord webhook fired ("new Patchcord messages").
14
+
15
+ ## Steps
16
+ 1. Call `mcp_patchcord_inbox`. The first header line is YOUR identity (the recipient). Each message's real sender is on its `From X` line — never confuse the header with the sender.
17
+ 2. For each pending message, classify and act:
18
+ - ACK ("thanks", "noted", "works", "ok", "👍") → close silently with `mcp_patchcord_reply(message_id, resolve=true)` and NO content. Never reply to an ack with text.
19
+ - BLOCKED (cannot do it right now) → `mcp_patchcord_reply(message_id, "<reason>", defer=true)`.
20
+ - ACTIONABLE → do the work first, THEN `mcp_patchcord_reply(message_id, "<concrete summary with file paths/results>")`.
21
+ 3. Tell the user who wrote, what they asked, and what you did.
22
+
23
+ Do the work before replying. Never reply with a bare acknowledgement.
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: patchcord-subscribe
3
+ description: Set up real-time Patchcord delivery so new messages wake this agent automatically via the gateway webhook bridge.
4
+ version: 1.0.0
5
+ platforms: [macos, linux]
6
+ metadata:
7
+ hermes:
8
+ tags: [patchcord, messaging, agents, mcp, automation]
9
+ category: integrations
10
+ ---
11
+ # Patchcord — subscribe (real-time)
12
+
13
+ Hermes has no per-turn background-listener tool. Real-time delivery uses the
14
+ gateway webhook plus the patchcord bridge: the bridge holds one realtime
15
+ connection and POSTs to a Hermes webhook on each new message, and the always-on
16
+ gateway injects a prompt that wakes the agent. No re-arm.
17
+
18
+ ## One-time setup
19
+ 1. Register a webhook route on the gateway:
20
+ `hermes webhook subscribe patchcord --prompt "You have new Patchcord messages. Run /patchcord-inbox now."`
21
+ Note the route URL it prints (e.g. `https://<gateway-host>/webhooks/patchcord`).
22
+ 2. Start the bridge under the gateway/tmux/systemd:
23
+ `PATCHCORD_HERMES_WEBHOOK=<route-url> patchcord subscribe --hermes`
24
+ It self-reconnects and survives JWT cycles — leave it running.
25
+
26
+ ## When the webhook fires
27
+ The gateway injects the prompt → run the **patchcord-inbox** skill: read inbox, reply to each message, report to the user.
28
+
29
+ ## No webhook available
30
+ Fall back to polling: `hermes cron create` a job that runs `/patchcord-inbox` every few minutes.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: patchcord-wait
3
+ description: Block and wait for one incoming Patchcord message (up to ~5 minutes).
4
+ version: 1.0.0
5
+ metadata:
6
+ hermes:
7
+ tags: [patchcord, messaging, agents, mcp]
8
+ category: integrations
9
+ ---
10
+ # Patchcord — wait
11
+
12
+ ## When to use
13
+ You sent a message and expect a reply, or the user asks you to wait for an incoming Patchcord message.
14
+
15
+ ## Steps
16
+ 1. Call `mcp_patchcord_wait_for_message` (blocks until a message arrives or ~5 minutes elapse).
17
+ 2. If a message arrives, classify and act per the **patchcord-inbox** skill: ACK → `mcp_patchcord_reply(id, resolve=true)` with no content; BLOCKED → `mcp_patchcord_reply(id, "<reason>", defer=true)`; ACTIONABLE → do the work, then `mcp_patchcord_reply(id, "<summary>")`.
18
+ 3. If it times out with nothing, tell the user nothing arrived.
@@ -14,6 +14,21 @@ import { URL } from "node:url";
14
14
  import { dirname } from "node:path";
15
15
  import { connect as wsConnect } from "./lib/ws.mjs";
16
16
 
17
+ // --- Hermes webhook bridge mode -------------------------------------------
18
+ // Default mode writes "PATCHCORD: ..." lines to stdout for Claude Code's
19
+ // Monitor. In --hermes mode there is no Monitor: we POST a small JSON payload
20
+ // to a Hermes gateway webhook on each new message, so the always-on gateway
21
+ // injects a prompt into the agent (real-time push, no re-arm needed).
22
+ const HERMES_MODE = process.argv.includes("--hermes");
23
+ const HERMES_WEBHOOK = (() => {
24
+ const i = process.argv.indexOf("--hermes");
25
+ const inline =
26
+ i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith("-")
27
+ ? process.argv[i + 1]
28
+ : null;
29
+ return process.env.PATCHCORD_HERMES_WEBHOOK || inline || null;
30
+ })();
31
+
17
32
  const JWT_REFRESH_SAFETY_MARGIN_SEC = 120;
18
33
  const HEARTBEAT_INTERVAL_MS = 25_000;
19
34
  const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 15_000, 30_000];
@@ -111,6 +126,32 @@ function httpJson(urlStr, { method = "GET", headers = {}, body = null } = {}) {
111
126
  });
112
127
  }
113
128
 
129
+ // Emit a new-message notification. Default mode → stdout line for Monitor.
130
+ // Hermes mode → POST JSON to the gateway webhook so it wakes the agent.
131
+ async function notify(line, meta = {}) {
132
+ if (!HERMES_MODE) {
133
+ process.stdout.write(line + "\n");
134
+ return;
135
+ }
136
+ if (!HERMES_WEBHOOK) return;
137
+ try {
138
+ const body = JSON.stringify({ source: "patchcord", text: line, ...meta });
139
+ const res = await httpJson(HERMES_WEBHOOK, {
140
+ method: "POST",
141
+ headers: {
142
+ "Content-Type": "application/json",
143
+ "Content-Length": Buffer.byteLength(body),
144
+ },
145
+ body,
146
+ });
147
+ if (res.status >= 400) {
148
+ logErr(`subscribe: hermes webhook HTTP ${res.status}: ${res.body.slice(0, 120)}`);
149
+ }
150
+ } catch (e) {
151
+ logErr(`subscribe: hermes webhook POST failed: ${e.message}`);
152
+ }
153
+ }
154
+
114
155
  async function fetchTicket(baseUrl, token) {
115
156
  const res = await httpJson(`${baseUrl}/api/realtime/ticket`, {
116
157
  headers: {
@@ -157,7 +198,7 @@ async function drainQueueOnce(baseUrl, token) {
157
198
  count = JSON.parse(res.body).pending_count ?? 0;
158
199
  } catch (_) {}
159
200
  if (count > 0) {
160
- process.stdout.write(`PATCHCORD: ${count} waiting in inbox\n`);
201
+ await notify(`PATCHCORD: ${count} waiting in inbox`, { count, kind: "pending" });
161
202
  }
162
203
  }
163
204
 
@@ -206,6 +247,13 @@ async function run() {
206
247
  const { baseUrl, token } = readMcpConfig(cwd);
207
248
  logErr(`subscribe: cwd=${cwd} server=${baseUrl}`);
208
249
 
250
+ if (HERMES_MODE) {
251
+ if (!HERMES_WEBHOOK) {
252
+ die("hermes mode: no webhook URL — set PATCHCORD_HERMES_WEBHOOK or pass --hermes <url>");
253
+ }
254
+ logErr(`subscribe: hermes webhook bridge → ${HERMES_WEBHOOK}`);
255
+ }
256
+
209
257
  let ticket = await fetchTicket(baseUrl, token);
210
258
  const pidfile = `/tmp/patchcord_subscribe_${ticket.namespace_ids[0]}_${ticket.agent_id}.pid`;
211
259
  writePidfile(pidfile);
@@ -225,18 +273,24 @@ async function run() {
225
273
  // default, so the EPIPE error surfaces on the stream instead. Without this
226
274
  // the process orphans after the session ends and the pidfile guard blocks
227
275
  // the next session from starting a fresh listener.
228
- process.stdout.on("error", (err) => {
229
- if (err.code === "EPIPE") {
276
+ //
277
+ // Skipped in Hermes mode: there is no Monitor consuming stdout — the bridge
278
+ // runs detached under the gateway/tmux, so stdout closing is not a signal to
279
+ // exit (it would kill the always-on listener).
280
+ if (!HERMES_MODE) {
281
+ process.stdout.on("error", (err) => {
282
+ if (err.code === "EPIPE") {
283
+ cleanup();
284
+ process.exit(0);
285
+ }
286
+ });
287
+
288
+ // Detect Monitor closing its end of the stdout pipe.
289
+ process.stdout.on("close", () => {
230
290
  cleanup();
231
291
  process.exit(0);
232
- }
233
- });
234
-
235
- // Detect Monitor closing its end of the stdout pipe.
236
- process.stdout.on("close", () => {
237
- cleanup();
238
- process.exit(0);
239
- });
292
+ });
293
+ }
240
294
 
241
295
  logErr(`subscribe: agent=${ticket.agent_id} namespaces=${ticket.namespace_ids.join(",")}`);
242
296
 
@@ -477,7 +531,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
477
531
  // no action from the recipient. Notifying on them is the root cause of ack loops.
478
532
  if (rec.thread_resolved_at) return;
479
533
  const from = rec.from_agent || "unknown";
480
- process.stdout.write(`PATCHCORD: 1 new from ${from}\n`);
534
+ notify(`PATCHCORD: 1 new from ${from}`, { from, count: 1, kind: "message" });
481
535
  });
482
536
 
483
537
  ws.on("error", (err) => {