patchcord 0.6.9 → 0.6.11
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
|
@@ -10,6 +10,7 @@ const HOME = homedir();
|
|
|
10
10
|
|
|
11
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const pluginRoot = join(__dirname, "..");
|
|
13
|
+
import { resolveProjectBearer, harnessContext } from "../scripts/lib/resolve-project-bearer.mjs";
|
|
13
14
|
const cmd = process.argv[2];
|
|
14
15
|
|
|
15
16
|
if (cmd === "--version" || cmd === "-v") {
|
|
@@ -42,30 +43,6 @@ function isSafeId(s) {
|
|
|
42
43
|
return /^[A-Za-z0-9_\-]+$/.test(s) && s.length < 100;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
const PROJECT_MARKERS = [
|
|
46
|
-
".git", "package.json", "package-lock.json", "Cargo.toml", "go.mod", "go.sum",
|
|
47
|
-
"pyproject.toml", "pom.xml", "build.gradle", "Makefile", "CMakeLists.txt",
|
|
48
|
-
"Gemfile", "composer.json", "mix.exs", "requirements.txt", "setup.py",
|
|
49
|
-
".claude", ".codex", ".cursor", ".grok", ".vscode", ".kimi", ".openclaw",
|
|
50
|
-
];
|
|
51
|
-
|
|
52
|
-
function detectFolder(dir) {
|
|
53
|
-
if (dir === HOME || dir === HOME + "/" || dir === "/") return "HOME";
|
|
54
|
-
for (const m of PROJECT_MARKERS) {
|
|
55
|
-
if (existsSync(join(dir, m))) return "PROJECT";
|
|
56
|
-
}
|
|
57
|
-
let entries;
|
|
58
|
-
try {
|
|
59
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
60
|
-
} catch { return "UNKNOWN"; }
|
|
61
|
-
if (entries.length === 0) return "EMPTY";
|
|
62
|
-
const files = entries.filter(e => e.isFile());
|
|
63
|
-
const dirs = entries.filter(e => e.isDirectory());
|
|
64
|
-
if (files.length === 0 && dirs.length >= 2) return "CONTAINER";
|
|
65
|
-
return "UNKNOWN";
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
46
|
// A trailing --help/-h on ANY subcommand must show help and exit — never run the
|
|
70
47
|
// command. (patchcord orchestrator --help used to PROVISION an orchestrator;
|
|
71
48
|
// patchcord login --help hung on the browser login. Side-effecting --help is a
|
|
@@ -158,13 +135,7 @@ if (cmd === "plugin-path") {
|
|
|
158
135
|
// opencode (per-project) + windsurf, gemini, zed, openclaw, antigravity,
|
|
159
136
|
// cline, kimi (global). Each tool stores the bearer in its own shape.
|
|
160
137
|
function _kimiContextRequested(options = {}) {
|
|
161
|
-
|
|
162
|
-
return options.preferKimi === true
|
|
163
|
-
|| forceTool === "kimi"
|
|
164
|
-
|| Boolean(process.env.KIMI_CLI)
|
|
165
|
-
|| Boolean(process.env.KIMI_SESSION_ID)
|
|
166
|
-
|| Boolean(process.env.KIMI_CONFIG_FILE)
|
|
167
|
-
|| Boolean(process.env.KIMI_MCP_CONFIG_FILE);
|
|
138
|
+
return harnessContext(process.env, options).preferKimi;
|
|
168
139
|
}
|
|
169
140
|
|
|
170
141
|
// Strip legacy Python kimi-cli per-project artifacts. Kimi Code (Node) uses
|
|
@@ -292,7 +263,9 @@ function readGrokTomlShape(path) {
|
|
|
292
263
|
|
|
293
264
|
async function _resolveBearer(options = {}) {
|
|
294
265
|
const { readFileSync } = await import("fs");
|
|
295
|
-
|
|
266
|
+
|
|
267
|
+
const projectFound = resolveProjectBearer(process.cwd(), options);
|
|
268
|
+
if (projectFound) return projectFound;
|
|
296
269
|
|
|
297
270
|
// Strict JSON first; fall back to JSONC stripping for zed/gemini-style
|
|
298
271
|
// settings (which allow // /* */ trailing commas). The fallback strips
|
|
@@ -381,30 +354,6 @@ async function _resolveBearer(options = {}) {
|
|
|
381
354
|
} catch { return null; }
|
|
382
355
|
};
|
|
383
356
|
|
|
384
|
-
// Per-project (walk up from cwd). First win.
|
|
385
|
-
const kimiProjectReader = (cwd) => readJsonAt(join(cwd, ".kimi", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
|
|
386
|
-
const kimiCodeProjectReader = (cwd) => readJsonAt(join(cwd, ".kimi-code", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
|
|
387
|
-
const defaultProjectReaders = [
|
|
388
|
-
(cwd) => readJsonAt(join(cwd, ".mcp.json"), ["mcpServers", "patchcord"], "claude_code"),
|
|
389
|
-
(cwd) => readJsonAt(join(cwd, ".cursor", "mcp.json"), ["mcpServers", "patchcord"], "cursor"),
|
|
390
|
-
(cwd) => readJsonAt(join(cwd, ".vscode", "mcp.json"), ["servers", "patchcord"], "vscode"),
|
|
391
|
-
(cwd) => readJsonAt(join(cwd, "opencode.json"), ["mcp", "patchcord"], "opencode"),
|
|
392
|
-
(cwd) => readJsonAt(join(cwd, ".agents", "mcp_config.json"), ["mcpServers", "patchcord"], "antigravity"),
|
|
393
|
-
(cwd) => readGrokTomlShape(join(cwd, ".grok", "config.toml")),
|
|
394
|
-
(cwd) => readCodexTomlShape(join(cwd, ".codex", "config.toml")),
|
|
395
|
-
];
|
|
396
|
-
const projectReaders = preferKimi
|
|
397
|
-
? [kimiProjectReader, kimiCodeProjectReader, ...defaultProjectReaders]
|
|
398
|
-
: [...defaultProjectReaders, kimiProjectReader, kimiCodeProjectReader];
|
|
399
|
-
let dir = process.cwd();
|
|
400
|
-
while (dir && dir !== "/") {
|
|
401
|
-
for (const r of projectReaders) {
|
|
402
|
-
const found = r(dir);
|
|
403
|
-
if (found) { found.scope = "project"; return found; }
|
|
404
|
-
}
|
|
405
|
-
dir = dirname(dir);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
357
|
// Global fallbacks. Order by likelihood for current install base.
|
|
409
358
|
const zedPath = process.platform === "darwin"
|
|
410
359
|
? join(HOME, "Library", "Application Support", "Zed", "settings.json")
|
|
@@ -929,8 +878,8 @@ if (cmd === "subscribe") {
|
|
|
929
878
|
// ── main flow: global setup + project setup (or just install/agent for back-compat) ──
|
|
930
879
|
// Any --flag enters this branch so equals-form (--token=foo, --tool=foo, --server=foo)
|
|
931
880
|
// works the same as the space-form (--token foo). The internal flag parsing below
|
|
932
|
-
// supports both. Non-flag commands (
|
|
933
|
-
//
|
|
881
|
+
// supports both. Non-flag commands (skill, help, plugin-path) have their own
|
|
882
|
+
// branches above and below.
|
|
934
883
|
// ── CLI account auth + orchestrator/team/provisioning/schedule ─────────────
|
|
935
884
|
// These commands act on the user's ACCOUNT, so they require CLI login. The
|
|
936
885
|
// account token (user-level, tied to NO agent) lives at ~/.patchcord/auth.json
|
|
@@ -1635,17 +1584,6 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
|
|
|
1635
1584
|
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
1636
1585
|
}
|
|
1637
1586
|
|
|
1638
|
-
function safeReadJson(filePath) {
|
|
1639
|
-
try {
|
|
1640
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
1641
|
-
// Try strict JSON first (avoids corrupting URLs with //)
|
|
1642
|
-
try { return JSON.parse(raw); } catch {}
|
|
1643
|
-
// Fall back to JSONC stripping for Zed/Gemini-style configs
|
|
1644
|
-
const cleaned = _stripJsoncOutsideStrings(raw);
|
|
1645
|
-
return cleaned.trim() ? JSON.parse(cleaned) : {};
|
|
1646
|
-
} catch { return null; }
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
1587
|
// Read+merge+write a JSON config file. If the file exists but its contents
|
|
1650
1588
|
// can't be parsed, REFUSE to write — silently overwriting would erase
|
|
1651
1589
|
// unrelated MCP servers, settings, or hand-edits the user has in there.
|
|
@@ -2270,7 +2208,8 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
|
|
|
2270
2208
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2271
2209
|
const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
|
|
2272
2210
|
|
|
2273
|
-
//
|
|
2211
|
+
// Harness choice: --tool=<slug>, browser connect client_type, or existing-token
|
|
2212
|
+
// refresh. There is no terminal tool picker — dashboard / connect page picks once.
|
|
2274
2213
|
let choice = "";
|
|
2275
2214
|
|
|
2276
2215
|
const CLIENT_TYPE_MAP = {
|
|
@@ -2306,8 +2245,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
|
|
|
2306
2245
|
// web UI; the installer skips its terminal picker, and the
|
|
2307
2246
|
// `&tool=<slug>` query param appended to the browser connectUrl below
|
|
2308
2247
|
// makes the web /connect page skip its picker too.
|
|
2309
|
-
// Unknown slugs
|
|
2310
|
-
// the existing interactive flow takes over.
|
|
2248
|
+
// Unknown slugs fall through; browser connect may default harness to Claude Code.
|
|
2311
2249
|
const toolFlag = flags.find(f => f.startsWith("--tool="))?.split("=")[1]
|
|
2312
2250
|
|| (flags.includes("--tool") ? flags[flags.indexOf("--tool") + 1] : "");
|
|
2313
2251
|
let toolSlug = "";
|
package/package.json
CHANGED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Shared per-project bearer resolution for patchcord.mjs and subscribe.mjs.
|
|
2
|
+
// When multiple tool configs exist in one directory (common: .mcp.json from
|
|
3
|
+
// Claude Code + .cursor/mcp.json from Cursor), pick the config matching the
|
|
4
|
+
// invoking harness instead of always preferring .mcp.json first.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
function stripJsoncOutsideStrings(raw) {
|
|
10
|
+
let out = "";
|
|
11
|
+
let i = 0;
|
|
12
|
+
let inStr = false;
|
|
13
|
+
let strCh = "";
|
|
14
|
+
let prev = "";
|
|
15
|
+
while (i < raw.length) {
|
|
16
|
+
const c = raw[i];
|
|
17
|
+
if (inStr) {
|
|
18
|
+
out += c;
|
|
19
|
+
if (c === strCh && prev !== "\\") inStr = false;
|
|
20
|
+
prev = c;
|
|
21
|
+
i++;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (c === '"' || c === "'") {
|
|
25
|
+
inStr = true;
|
|
26
|
+
strCh = c;
|
|
27
|
+
out += c;
|
|
28
|
+
prev = c;
|
|
29
|
+
i++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (c === "/" && raw[i + 1] === "/") {
|
|
33
|
+
while (i < raw.length && raw[i] !== "\n") i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (c === "/" && raw[i + 1] === "*") {
|
|
37
|
+
i += 2;
|
|
38
|
+
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
|
|
39
|
+
i += 2;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
out += c;
|
|
43
|
+
prev = c;
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseJsonc(raw) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(raw);
|
|
52
|
+
} catch {}
|
|
53
|
+
return JSON.parse(stripJsoncOutsideStrings(raw));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractBearer(entry) {
|
|
57
|
+
if (!entry) return null;
|
|
58
|
+
const auth = entry?.headers?.Authorization;
|
|
59
|
+
const url = entry.url || entry.httpUrl || entry.serverUrl;
|
|
60
|
+
if (!auth || !url) return null;
|
|
61
|
+
return {
|
|
62
|
+
token: auth.replace(/^Bearer\s+/i, ""),
|
|
63
|
+
baseUrl: url.replace(/\/mcp(\/bearer)?$/, ""),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readJsonAt(path, keyPath, tool) {
|
|
68
|
+
if (!existsSync(path)) return null;
|
|
69
|
+
try {
|
|
70
|
+
const obj = parseJsonc(readFileSync(path, "utf-8"));
|
|
71
|
+
const entry = keyPath.reduce((o, k) => o?.[k], obj);
|
|
72
|
+
const b = extractBearer(entry);
|
|
73
|
+
return b ? { ...b, configFile: path, tool } : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readCodexTomlShape(path) {
|
|
80
|
+
if (!existsSync(path)) return null;
|
|
81
|
+
try {
|
|
82
|
+
const content = readFileSync(path, "utf-8");
|
|
83
|
+
const block = content.match(/\[mcp_servers\.patchcord[-\w]*\]([\s\S]*?)(?=\n\[|$)/);
|
|
84
|
+
if (!block) return null;
|
|
85
|
+
const urlMatch = block[1].match(/url\s*=\s*"([^"]+)"/);
|
|
86
|
+
const tokenMatch = block[1].match(/Bearer\s+([^\s"]+)/);
|
|
87
|
+
if (!urlMatch || !tokenMatch) return null;
|
|
88
|
+
return {
|
|
89
|
+
token: tokenMatch[1],
|
|
90
|
+
baseUrl: urlMatch[1].replace(/\/mcp(\/bearer)?$/, ""),
|
|
91
|
+
configFile: path,
|
|
92
|
+
tool: "codex",
|
|
93
|
+
};
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readGrokTomlShape(path) {
|
|
100
|
+
if (!existsSync(path)) return null;
|
|
101
|
+
try {
|
|
102
|
+
const content = readFileSync(path, "utf-8");
|
|
103
|
+
const header = content.match(/^\[mcp_servers\.patchcord(?:[-\w]*)?\]\s*$/m);
|
|
104
|
+
if (!header) return null;
|
|
105
|
+
const rest = content.slice(header.index + header[0].length);
|
|
106
|
+
const nextSection = rest.search(/^\[(?!mcp_servers\.patchcord(?:\.|[-\w]*\]))/m);
|
|
107
|
+
const block = nextSection === -1 ? rest : rest.slice(0, nextSection);
|
|
108
|
+
const urlMatch = block.match(/^\s*url\s*=\s*["']([^"']+)["']/m);
|
|
109
|
+
const tokenMatch = block.match(/["']?Authorization["']?\s*=\s*["']Bearer\s+([^"']+)["']/i);
|
|
110
|
+
if (!urlMatch || !tokenMatch) return null;
|
|
111
|
+
return {
|
|
112
|
+
token: tokenMatch[1],
|
|
113
|
+
baseUrl: urlMatch[1].replace(/\/mcp(\/bearer)?$/, ""),
|
|
114
|
+
configFile: path,
|
|
115
|
+
tool: "grok",
|
|
116
|
+
};
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const claudeReader = (cwd) => readJsonAt(join(cwd, ".mcp.json"), ["mcpServers", "patchcord"], "claude_code");
|
|
123
|
+
const cursorReader = (cwd) => readJsonAt(join(cwd, ".cursor", "mcp.json"), ["mcpServers", "patchcord"], "cursor");
|
|
124
|
+
const vscodeReader = (cwd) => readJsonAt(join(cwd, ".vscode", "mcp.json"), ["servers", "patchcord"], "vscode");
|
|
125
|
+
const opencodeReader = (cwd) => readJsonAt(join(cwd, "opencode.json"), ["mcp", "patchcord"], "opencode");
|
|
126
|
+
const antigravityReader = (cwd) => readJsonAt(join(cwd, ".agents", "mcp_config.json"), ["mcpServers", "patchcord"], "antigravity");
|
|
127
|
+
const grokReader = (cwd) => readGrokTomlShape(join(cwd, ".grok", "config.toml"));
|
|
128
|
+
const codexReader = (cwd) => readCodexTomlShape(join(cwd, ".codex", "config.toml"));
|
|
129
|
+
const kimiReader = (cwd) => readJsonAt(join(cwd, ".kimi", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
|
|
130
|
+
const kimiCodeReader = (cwd) => readJsonAt(join(cwd, ".kimi-code", "mcp.json"), ["mcpServers", "patchcord"], "kimi");
|
|
131
|
+
|
|
132
|
+
/** Detect which harness launched this process (env + explicit overrides). */
|
|
133
|
+
export function harnessContext(env = process.env, options = {}) {
|
|
134
|
+
const forceTool = (env.PATCHCORD_FORCE_TOOL || env.PATCHCORD_TOOL || "").toLowerCase();
|
|
135
|
+
const preferKimi =
|
|
136
|
+
options.preferKimi === true
|
|
137
|
+
|| forceTool === "kimi"
|
|
138
|
+
|| Boolean(env.KIMI_CLI)
|
|
139
|
+
|| Boolean(env.KIMI_SESSION_ID)
|
|
140
|
+
|| Boolean(env.KIMI_CONFIG_FILE)
|
|
141
|
+
|| Boolean(env.KIMI_MCP_CONFIG_FILE);
|
|
142
|
+
const preferCursor =
|
|
143
|
+
options.preferCursor === true
|
|
144
|
+
|| forceTool === "cursor"
|
|
145
|
+
|| Boolean(env.CURSOR_AGENT);
|
|
146
|
+
return { forceTool, preferKimi, preferCursor };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Ordered per-project readers for the active harness. */
|
|
150
|
+
export function projectReadersForContext(ctx) {
|
|
151
|
+
const defaultReaders = [
|
|
152
|
+
claudeReader,
|
|
153
|
+
cursorReader,
|
|
154
|
+
vscodeReader,
|
|
155
|
+
opencodeReader,
|
|
156
|
+
antigravityReader,
|
|
157
|
+
grokReader,
|
|
158
|
+
codexReader,
|
|
159
|
+
];
|
|
160
|
+
const kimiReaders = [kimiReader, kimiCodeReader];
|
|
161
|
+
|
|
162
|
+
if (ctx.preferKimi) {
|
|
163
|
+
return [...kimiReaders, ...defaultReaders];
|
|
164
|
+
}
|
|
165
|
+
if (ctx.preferCursor) {
|
|
166
|
+
// Cursor CLI/IDE: .cursor/mcp.json must win over stale .mcp.json in the
|
|
167
|
+
// same repo (e.g. leftover Claude Code token from an old provision).
|
|
168
|
+
return [
|
|
169
|
+
cursorReader,
|
|
170
|
+
vscodeReader,
|
|
171
|
+
opencodeReader,
|
|
172
|
+
antigravityReader,
|
|
173
|
+
grokReader,
|
|
174
|
+
codexReader,
|
|
175
|
+
claudeReader,
|
|
176
|
+
...kimiReaders,
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
return [...defaultReaders, ...kimiReaders];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Walk up from startDir; return first matching project bearer or null. */
|
|
183
|
+
export function resolveProjectBearer(startDir, options = {}) {
|
|
184
|
+
const ctx = harnessContext(process.env, options);
|
|
185
|
+
const readers = projectReadersForContext(ctx);
|
|
186
|
+
let dir = startDir;
|
|
187
|
+
while (dir && dir !== "/") {
|
|
188
|
+
for (const r of readers) {
|
|
189
|
+
const found = r(dir);
|
|
190
|
+
if (found) {
|
|
191
|
+
found.scope = "project";
|
|
192
|
+
return found;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const parent = dirname(dir);
|
|
196
|
+
if (parent === dir) break;
|
|
197
|
+
dir = parent;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
package/scripts/subscribe.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { request as httpRequest } from "node:http";
|
|
|
13
13
|
import { URL } from "node:url";
|
|
14
14
|
import { dirname } from "node:path";
|
|
15
15
|
import { connect as wsConnect } from "./lib/ws.mjs";
|
|
16
|
+
import { resolveProjectBearer } from "./lib/resolve-project-bearer.mjs";
|
|
16
17
|
|
|
17
18
|
// --- Hermes webhook bridge mode -------------------------------------------
|
|
18
19
|
// Default mode writes "PATCHCORD: ..." lines to stdout for Claude Code's
|
|
@@ -70,35 +71,15 @@ function die(msg, code = 1) {
|
|
|
70
71
|
|
|
71
72
|
function readMcpConfig(cwd) {
|
|
72
73
|
// Prefer env vars injected by patchcord.mjs, which already ran _resolveBearer()
|
|
73
|
-
// and supports all
|
|
74
|
+
// and supports all tool configs with harness-aware ordering.
|
|
74
75
|
if (process.env.PATCHCORD_BASE_URL && process.env.PATCHCORD_BEARER_TOKEN) {
|
|
75
76
|
return { baseUrl: process.env.PATCHCORD_BASE_URL, token: process.env.PATCHCORD_BEARER_TOKEN };
|
|
76
77
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const path = `${dir}/.mcp.json`;
|
|
81
|
-
if (existsSync(path)) {
|
|
82
|
-
let json;
|
|
83
|
-
try {
|
|
84
|
-
json = JSON.parse(readFileSync(path, "utf8"));
|
|
85
|
-
} catch (e) {
|
|
86
|
-
die(`.mcp.json parse error: ${e.message}`);
|
|
87
|
-
}
|
|
88
|
-
const pc = json?.mcpServers?.patchcord;
|
|
89
|
-
if (!pc?.url || !pc?.headers?.Authorization) {
|
|
90
|
-
die(".mcp.json missing mcpServers.patchcord.url or Authorization");
|
|
91
|
-
}
|
|
92
|
-
let baseUrl = pc.url.replace(/\/mcp\/bearer$/, "").replace(/\/mcp$/, "");
|
|
93
|
-
const auth = pc.headers.Authorization;
|
|
94
|
-
const token = auth.startsWith("Bearer ") ? auth.slice(7) : auth;
|
|
95
|
-
return { baseUrl, token };
|
|
96
|
-
}
|
|
97
|
-
const parent = dirname(dir);
|
|
98
|
-
if (parent === dir) break;
|
|
99
|
-
dir = parent;
|
|
78
|
+
const found = resolveProjectBearer(cwd);
|
|
79
|
+
if (!found) {
|
|
80
|
+
die(`no patchcord config found — run from a project directory or use 'patchcord subscribe'`);
|
|
100
81
|
}
|
|
101
|
-
|
|
82
|
+
return { baseUrl: found.baseUrl, token: found.token };
|
|
102
83
|
}
|
|
103
84
|
|
|
104
85
|
function httpJson(urlStr, { method = "GET", headers = {}, body = null } = {}) {
|
package/commands/subscribe.toml
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
prompt = "Start the Patchcord background listener per the patchcord:subscribe skill. Spawn scripts/subscribe.mjs with run_in_background, attach the Monitor tool to its stdout, and when a 'PATCHCORD:' line appears, announce it briefly and call inbox() to handle any new messages."
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: patchcord
|
|
3
|
-
description: >
|
|
4
|
-
Cross-agent messaging via Patchcord MCP connector. Use when the user mentions
|
|
5
|
-
other agents, checking inbox, sending messages, who's online, or agent coordination.
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
# Patchcord - cross-agent messaging
|
|
9
|
-
|
|
10
|
-
You are connected to Patchcord, a message bus that lets you talk to AI agents on other machines and platforms.
|
|
11
|
-
|
|
12
|
-
## Tools
|
|
13
|
-
|
|
14
|
-
- **inbox(all_agents?)** - read pending messages + recent activity. Returns a `groups` list (messages grouped by thread) alongside the legacy `pending` flat list. `all_agents=true` includes inactive agents. Presence tells you whether to wait for a reply, not whether to send.
|
|
15
|
-
- **send_message(to_agent, content, thread?)** - send a message. Comma-separated for multiple: `send_message("backend, frontend", "hello")`. Supports `@username` for cross-user Gate messaging. `thread` slug starts or joins a named thread: `send_message("backend", "...", thread="deploy-review")`. Up to 50,000 characters — never summarize.
|
|
16
|
-
- **reply(message_id, content?, defer?, resolve?)** - reply to a received message. Auto-inherits thread from the original. `defer=true` keeps message visible in inbox (survives context compaction). `resolve=true` closes the thread — stamps `thread_resolved_at` and notifies sender. Content optional: `reply(message_id, resolve=true)` to silently close.
|
|
17
|
-
- **wait_for_message(timeout_seconds?)** - block until incoming message arrives. Default 300s. Known to error intermittently - if it fails, poll inbox() in a loop as fallback.
|
|
18
|
-
- **attachment(...)** - file operations (see File sharing section below)
|
|
19
|
-
- **recall(limit?, from_agent?, thread_id?)** - view recent message history including already-read messages. `from_agent` filters by sender. `thread_id` filters to a specific thread. Debugging only.
|
|
20
|
-
- **unsend(message_id)** - take back a message before recipient reads it.
|
|
21
|
-
|
|
22
|
-
## Chat identification
|
|
23
|
-
|
|
24
|
-
You may be one of several chat sessions sharing the same Patchcord identity. To avoid confusion:
|
|
25
|
-
|
|
26
|
-
**When sending messages**, always prepend a brief chat context tag:
|
|
27
|
-
```
|
|
28
|
-
[marketing] Here are the Q1 metrics you asked for...
|
|
29
|
-
[dev-backend] The API endpoint is at /api/v2/users...
|
|
30
|
-
[general] Quick question about the deployment schedule
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Use the dominant topic of your current conversation as the tag. Keep it short (1-3 words). Be consistent within a session - pick a tag early and reuse it.
|
|
34
|
-
|
|
35
|
-
**When receiving messages**, check the context tag:
|
|
36
|
-
- If it matches your chat's topic — reply normally
|
|
37
|
-
- If it's clearly for another chat session — `reply(message_id, "→ [tag] chat", defer=true)`. Minimal content, no explanation.
|
|
38
|
-
- If there's no tag but the content is addressed to a different role (e.g. "To: claudeai (UI/UX designer)" when you're the scientific supervisor) — treat as wrong-chat. `reply(message_id, "→ other session", defer=true)`. Do not explain your role or what the other session should do.
|
|
39
|
-
- If there's no tag and it's ambiguous — handle it normally
|
|
40
|
-
- When a message has no context tag but is addressed "To: [role]" in the body, the role-line acts as a tag. Route accordingly.
|
|
41
|
-
|
|
42
|
-
## Behavioral rules
|
|
43
|
-
|
|
44
|
-
1. **Call inbox() at the start of every conversation** to see pending messages. Reply to or resolve anything actionable before doing other work.
|
|
45
|
-
|
|
46
|
-
2. **Reply immediately** to pending messages. Do not ask "should I reply?" - just reply, then tell the user what you received and what you answered.
|
|
47
|
-
|
|
48
|
-
3. **Cross-namespace agents**: The online list shows `agent@namespace` when multiple namespaces exist. Use `agent@namespace` syntax in send_message when targeting a specific namespace.
|
|
49
|
-
|
|
50
|
-
4. **Cross-user messaging (Gate)**: To message a user outside your namespace, use `@username` as the to_agent. Example: `send_message("@maria", "hello")`. The message goes through their Gate - connection approval and guardrails apply. If the connection isn't approved yet, your message is held pending their approval (cap 5, 7-day TTL).
|
|
51
|
-
|
|
52
|
-
5. **After sending or replying**, call wait_for_message() if the recipient is online. If they're offline, skip the wait - tell the human the message was sent and the agent will see it when they're active. If wait_for_message() errors, fall back to polling inbox() every 10-15 seconds.
|
|
53
|
-
|
|
54
|
-
6. **Never show raw JSON** - summarize naturally.
|
|
55
|
-
|
|
56
|
-
7. **Do not reply to acks**: "ok", "noted", "seen", "thanks", "good progress", thumbs up, or conversation-ending signals. If you must close the thread: `reply(id, resolve=true)` with NO content. Never send a text reply to an ack. **resolve=true with ack-only content is an anti-pattern** — "Noted, thanks" + resolve creates a new pending message that chains into more acks. Omit content when there's nothing substantive. When you receive an ack, close it silently: `reply(id, resolve=true)`. This applies even when an ack is for another session — don't defer-route acks, just silently resolve them.
|
|
57
|
-
|
|
58
|
-
8. **Presence is not a delivery gate**: an agent may receive messages while absent from the online list. Always send regardless of online/offline status. Messages queue and deliver when the recipient checks inbox.
|
|
59
|
-
|
|
60
|
-
9. **Blocked sends mean unread inbox.** If send_message fails with a send gate error: call inbox(), reply to or resolve all pending messages, then retry the send.
|
|
61
|
-
|
|
62
|
-
10. **MCP tools are cached at session start.** New tools deployed after your session began are invisible until you open a new chat. If a tool you expect is missing, this is why.
|
|
63
|
-
|
|
64
|
-
## Threads
|
|
65
|
-
|
|
66
|
-
Named threads group related messages. Use them for multi-turn tasks that need their own context.
|
|
67
|
-
|
|
68
|
-
- **Start**: `send_message("backend", "...", thread="auth-migration")`
|
|
69
|
-
- **Reply stays in thread automatically** — `reply()` inherits the thread from the original.
|
|
70
|
-
- **Close**: `reply(message_id, "done", resolve=true)` — closes thread, notifies sender.
|
|
71
|
-
- **Filter history**: `recall(thread_id="<uuid>")` — only that thread's messages.
|
|
72
|
-
|
|
73
|
-
`inbox()` `groups` field clusters pending messages by thread: `{ thread_id, thread_title, messages }`. `thread_id: null` = pair-level (no thread).
|
|
74
|
-
|
|
75
|
-
## Sending workflow
|
|
76
|
-
|
|
77
|
-
1. inbox() - clear pending messages that block outbound sends. Note who's online (determines whether to wait after sending).
|
|
78
|
-
2. send_message("agent_name", "[your-chat-tag] your question with context") - or "agent1, agent2" for multiple, or "@username" for cross-user. Add `thread="slug"` to group in a named thread.
|
|
79
|
-
3. If recipient is online: wait_for_message() - block until response arrives. If offline: skip wait, tell the human the message is queued.
|
|
80
|
-
|
|
81
|
-
ALWAYS send regardless of online/offline status. Messages are stored and delivered when the recipient checks inbox. Never refuse to send because an agent appears offline.
|
|
82
|
-
|
|
83
|
-
After sending to an offline agent, tell the human: "Message sent. [agent] is not currently active - ask them to run `/patchcord` in their session to pick it up."
|
|
84
|
-
|
|
85
|
-
## Receiving workflow
|
|
86
|
-
|
|
87
|
-
Action requests older than 7d (per the `(Xd ago)` stamp): ask human before executing. Acks/FYIs silent-resolve at any age.
|
|
88
|
-
|
|
89
|
-
1. Read messages from inbox(). Check `message.thread` / `message.thread_id` if present.
|
|
90
|
-
2. Check the context tag - is this for your chat?
|
|
91
|
-
3. If yes: do the work, then reply with the right flag:
|
|
92
|
-
- `reply(message_id, "[tag] done: [details]")` — work done, thread auto-inherited
|
|
93
|
-
- `reply(message_id, "[tag] done", resolve=true)` — work done, thread closed
|
|
94
|
-
- `reply(message_id, "[tag] ack, will do after [other task]", defer=true)` — acknowledged but work not done yet. Message stays in inbox.
|
|
95
|
-
4. If no: reply(message_id, "For [other-tag] chat", defer=true)
|
|
96
|
-
5. wait_for_message() - stay responsive for follow-ups
|
|
97
|
-
|
|
98
|
-
When you have multiple pending messages, prioritize by urgency. Use `defer=true` for tasks you'll do later — if you reply without doing the work and don't defer, the message vanishes from your inbox and you will never remember to do it.
|
|
99
|
-
|
|
100
|
-
Outdated deferred (work likely done, sender moved on): ask human "resolve [Xd]-old from [sender]?" before `reply(id, resolve=true)`. Don't unilaterally drop.
|
|
101
|
-
|
|
102
|
-
## File sharing
|
|
103
|
-
|
|
104
|
-
As a web agent, you CANNOT PUT to presigned URLs (egress is blocked). Two options:
|
|
105
|
-
|
|
106
|
-
### Option 1: Inline base64 upload (small files only)
|
|
107
|
-
```
|
|
108
|
-
attachment(upload=true, filename="report.md", file_data="<base64 encoded content>")
|
|
109
|
-
```
|
|
110
|
-
The server uploads for you. Send the returned path to the other agent in your message. Base64 adds ~33% overhead. Keep files small - text files, configs, short docs.
|
|
111
|
-
|
|
112
|
-
### Option 2: Relay from URL (preferred for public files)
|
|
113
|
-
```
|
|
114
|
-
attachment(relay=true, path_or_url="https://example.com/file.md", filename="file.md")
|
|
115
|
-
```
|
|
116
|
-
Server fetches the URL and stores it. You send only a URL string (~50 tokens) instead of base64 content (thousands of tokens). Always prefer relay when the file is at a public HTTPS URL.
|
|
117
|
-
|
|
118
|
-
### Receiving files
|
|
119
|
-
```
|
|
120
|
-
attachment(path_or_url="namespace/agent/filename.ext")
|
|
121
|
-
```
|
|
122
|
-
Use the path from the sender's message.
|
|
123
|
-
|
|
124
|
-
## Agent names
|
|
125
|
-
|
|
126
|
-
Agent names change frequently. Do not memorize or hardcode them. Check inbox() for recent activity. When unsure which agent to message, ask the human. Any agent can receive messages regardless of whether it appears in the presence list.
|