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 +172 -27
- package/package.json +1 -1
- package/per-project-skills/hermes/patchcord-inbox/SKILL.md +23 -0
- package/per-project-skills/hermes/patchcord-subscribe/SKILL.md +30 -0
- package/per-project-skills/hermes/patchcord-wait/SKILL.md +18 -0
- package/scripts/subscribe.mjs +66 -12
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
|
|
1284
|
-
|
|
1285
|
-
|
|
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
|
-
//
|
|
2144
|
-
|
|
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
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
if (
|
|
2158
|
-
|
|
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
|
@@ -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.
|
package/scripts/subscribe.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
534
|
+
notify(`PATCHCORD: 1 new from ${from}`, { from, count: 1, kind: "message" });
|
|
481
535
|
});
|
|
482
536
|
|
|
483
537
|
ws.on("error", (err) => {
|