patchcord 0.5.54 → 0.5.56

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
@@ -74,6 +74,12 @@ Usage:
74
74
  patchcord --token <token> [--server <url>] Headless / self-hosted setup
75
75
  patchcord --full Same + full statusline
76
76
  patchcord --rename <new-name> [--agent-type <type>] Rename this agent
77
+ patchcord whoami Show your identity + project
78
+ patchcord whoami --propose "<text>" Propose an update to your whoami
79
+ patchcord whoami --approve <id> Approve a pending proposal
80
+ patchcord whoami --reject <id> Reject a pending proposal
81
+ patchcord agents List every agent (with whoami)
82
+ patchcord agents <name> Show one agent's whoami
77
83
  patchcord subscribe Start the realtime listener
78
84
  patchcord update Update to the latest version
79
85
  patchcord --version Show installed version
@@ -88,6 +94,312 @@ if (cmd === "plugin-path") {
88
94
  process.exit(0);
89
95
  }
90
96
 
97
+ // Shared bearer/base-url resolver. Project-local configs win over globals.
98
+ // Supports all 11 installer targets: claude_code, codex, cursor, vscode,
99
+ // opencode (per-project) + windsurf, gemini, zed, openclaw, antigravity,
100
+ // cline (global). Each tool stores the bearer in its own shape.
101
+ async function _resolveBearer() {
102
+ const { readFileSync } = await import("fs");
103
+
104
+ // Strict JSON first; fall back to JSONC stripping for zed/gemini-style
105
+ // settings (which allow // /* */ trailing commas). The fallback strips
106
+ // ONLY outside string literals so URLs like "https://..." survive.
107
+ const _stripJsoncOutsideStrings = (raw) => {
108
+ let out = "";
109
+ let i = 0;
110
+ let inStr = false;
111
+ let strCh = "";
112
+ let prev = "";
113
+ while (i < raw.length) {
114
+ const c = raw[i];
115
+ if (inStr) {
116
+ out += c;
117
+ if (c === strCh && prev !== "\\") inStr = false;
118
+ prev = c;
119
+ i++;
120
+ continue;
121
+ }
122
+ if (c === '"' || c === "'") {
123
+ inStr = true;
124
+ strCh = c;
125
+ out += c;
126
+ prev = c;
127
+ i++;
128
+ continue;
129
+ }
130
+ if (c === "/" && raw[i + 1] === "/") {
131
+ while (i < raw.length && raw[i] !== "\n") i++;
132
+ continue;
133
+ }
134
+ if (c === "/" && raw[i + 1] === "*") {
135
+ i += 2;
136
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
137
+ i += 2;
138
+ continue;
139
+ }
140
+ out += c;
141
+ prev = c;
142
+ i++;
143
+ }
144
+ return out.replace(/,(\s*[}\]])/g, "$1");
145
+ };
146
+ const _parseJsonc = (raw) => {
147
+ try { return JSON.parse(raw); } catch {}
148
+ return JSON.parse(_stripJsoncOutsideStrings(raw));
149
+ };
150
+
151
+ const _extractBearer = (entry) => {
152
+ if (!entry) return null;
153
+ const auth = entry?.headers?.Authorization;
154
+ // Each tool has its own url key; check all known variants.
155
+ const url = entry.url || entry.httpUrl || entry.serverUrl;
156
+ if (!auth || !url) return null;
157
+ return {
158
+ token: auth.replace(/^Bearer\s+/i, ""),
159
+ baseUrl: url.replace(/\/mcp(\/bearer)?$/, ""),
160
+ };
161
+ };
162
+
163
+ // Shape readers: each returns {token, baseUrl, configFile, tool} or null.
164
+ const readJsonAt = (path, keyPath, tool) => {
165
+ if (!existsSync(path)) return null;
166
+ try {
167
+ const obj = _parseJsonc(readFileSync(path, "utf-8"));
168
+ const entry = keyPath.reduce((o, k) => o?.[k], obj);
169
+ const b = _extractBearer(entry);
170
+ return b ? { ...b, configFile: path, tool } : null;
171
+ } catch { return null; }
172
+ };
173
+ const readCodexTomlShape = (path) => {
174
+ if (!existsSync(path)) return null;
175
+ try {
176
+ const content = readFileSync(path, "utf-8");
177
+ const block = content.match(/\[mcp_servers\.patchcord[-\w]*\]([\s\S]*?)(?=\n\[|$)/);
178
+ if (!block) return null;
179
+ const urlMatch = block[1].match(/url\s*=\s*"([^"]+)"/);
180
+ const tokenMatch = block[1].match(/Bearer\s+([^\s"]+)/);
181
+ if (!urlMatch || !tokenMatch) return null;
182
+ return {
183
+ token: tokenMatch[1],
184
+ baseUrl: urlMatch[1].replace(/\/mcp(\/bearer)?$/, ""),
185
+ configFile: path,
186
+ tool: "codex",
187
+ };
188
+ } catch { return null; }
189
+ };
190
+
191
+ // Per-project (walk up from cwd). First win.
192
+ const projectReaders = [
193
+ (cwd) => readJsonAt(join(cwd, ".mcp.json"), ["mcpServers", "patchcord"], "claude_code"),
194
+ (cwd) => readJsonAt(join(cwd, ".cursor", "mcp.json"), ["mcpServers", "patchcord"], "cursor"),
195
+ (cwd) => readJsonAt(join(cwd, ".vscode", "mcp.json"), ["servers", "patchcord"], "vscode"),
196
+ (cwd) => readJsonAt(join(cwd, "opencode.json"), ["mcp", "patchcord"], "opencode"),
197
+ (cwd) => readCodexTomlShape(join(cwd, ".codex", "config.toml")),
198
+ ];
199
+ let dir = process.cwd();
200
+ while (dir && dir !== "/") {
201
+ for (const r of projectReaders) {
202
+ const found = r(dir);
203
+ if (found) return found;
204
+ }
205
+ dir = dirname(dir);
206
+ }
207
+
208
+ // Global fallbacks. Order by likelihood for current install base.
209
+ const zedPath = process.platform === "darwin"
210
+ ? join(HOME, "Library", "Application Support", "Zed", "settings.json")
211
+ : join(HOME, ".config", "zed", "settings.json");
212
+
213
+ // Cline lives in VS Code globalStorage — try stable / insiders / cursor.
214
+ const clinePaths = (() => {
215
+ const variants = process.platform === "darwin"
216
+ ? [
217
+ join(HOME, "Library", "Application Support", "Code", "User", "globalStorage"),
218
+ join(HOME, "Library", "Application Support", "Code - Insiders", "User", "globalStorage"),
219
+ join(HOME, "Library", "Application Support", "Cursor", "User", "globalStorage"),
220
+ ]
221
+ : process.platform === "win32"
222
+ ? [
223
+ join(process.env.APPDATA || join(HOME, "AppData", "Roaming"), "Code", "User", "globalStorage"),
224
+ join(process.env.APPDATA || join(HOME, "AppData", "Roaming"), "Code - Insiders", "User", "globalStorage"),
225
+ join(process.env.APPDATA || join(HOME, "AppData", "Roaming"), "Cursor", "User", "globalStorage"),
226
+ ]
227
+ : [
228
+ join(HOME, ".config", "Code", "User", "globalStorage"),
229
+ join(HOME, ".config", "Code - Insiders", "User", "globalStorage"),
230
+ join(HOME, ".config", "Cursor", "User", "globalStorage"),
231
+ ];
232
+ return variants.map((b) =>
233
+ join(b, "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json")
234
+ );
235
+ })();
236
+
237
+ const globalCandidates = [
238
+ () => readJsonAt(join(HOME, ".codeium", "windsurf", "mcp_config.json"), ["mcpServers", "patchcord"], "windsurf"),
239
+ () => readJsonAt(join(HOME, ".gemini", "settings.json"), ["mcpServers", "patchcord"], "gemini"),
240
+ () => readJsonAt(zedPath, ["context_servers", "patchcord"], "zed"),
241
+ () => readJsonAt(join(HOME, ".openclaw", "openclaw.json"), ["mcp", "servers", "patchcord"], "openclaw"),
242
+ () => readJsonAt(join(HOME, ".gemini", "antigravity", "mcp_config.json"), ["mcpServers", "patchcord"], "antigravity"),
243
+ ...clinePaths.map((p) => () => readJsonAt(p, ["mcpServers", "patchcord"], "cline")),
244
+ ];
245
+ for (const r of globalCandidates) {
246
+ const found = r();
247
+ if (found) return found;
248
+ }
249
+
250
+ return null;
251
+ }
252
+
253
+ async function _httpJSON(method, url, token, body) {
254
+ const tmpResp = `/tmp/patchcord-${process.pid}-${Date.now()}.json`;
255
+ const dataArg = body ? `-d '${JSON.stringify(body).replace(/'/g, "'\\''")}' ` : "";
256
+ const headers = `-H "Authorization: Bearer ${token}" -H "Content-Type: application/json" `;
257
+ const statusCode = run(
258
+ `curl -s -o "${tmpResp}" -w "%{http_code}" -X ${method} ${headers}${dataArg}"${url}"`
259
+ );
260
+ const { readFileSync } = await import("fs");
261
+ let respBody = "";
262
+ try { respBody = readFileSync(tmpResp, "utf-8"); } catch {}
263
+ try { (await import("fs")).unlinkSync(tmpResp); } catch {}
264
+ let json = null;
265
+ try { json = JSON.parse(respBody); } catch {}
266
+ return { status: statusCode, body: respBody, json };
267
+ }
268
+
269
+ // ── whoami ────────────────────────────────────────────────────
270
+ if (cmd === "whoami") {
271
+ const args = process.argv.slice(3);
272
+ const found = await _resolveBearer();
273
+ if (!found) {
274
+ console.error("No patchcord config found in current directory or any parent. Run `npx patchcord@latest` from a project directory first.");
275
+ process.exit(1);
276
+ }
277
+ const { token, baseUrl } = found;
278
+ if (!isSafeToken(token) || !isSafeUrl(baseUrl)) {
279
+ console.error(`Invalid patchcord URL or token in config.`);
280
+ process.exit(1);
281
+ }
282
+
283
+ // --propose "<text>"
284
+ const proposeIdx = args.indexOf("--propose");
285
+ if (proposeIdx !== -1) {
286
+ const text = (args[proposeIdx + 1] || "").trim();
287
+ if (!text) {
288
+ console.error("Usage: patchcord whoami --propose \"<text>\"");
289
+ process.exit(1);
290
+ }
291
+ if (text.length > 300) {
292
+ console.error(`Whoami text exceeds 300 characters (got ${text.length}).`);
293
+ process.exit(1);
294
+ }
295
+ const { status, json, body } = await _httpJSON("POST", `${baseUrl}/api/agent/whoami/propose`, token, { text });
296
+ if (status !== "200") {
297
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
298
+ process.exit(1);
299
+ }
300
+ console.log(`proposal_id: ${json.proposal_id}`);
301
+ console.log(`status: ${json.status}`);
302
+ console.log(`awaiting human approval. another agent or the dashboard must approve.`);
303
+ process.exit(0);
304
+ }
305
+
306
+ // --approve <id> / --reject <id>
307
+ const approveIdx = args.indexOf("--approve");
308
+ const rejectIdx = args.indexOf("--reject");
309
+ if (approveIdx !== -1 || rejectIdx !== -1) {
310
+ const isApprove = approveIdx !== -1;
311
+ const idx = isApprove ? approveIdx : rejectIdx;
312
+ const id = (args[idx + 1] || "").trim();
313
+ if (!id) {
314
+ console.error(`Usage: patchcord whoami --${isApprove ? "approve" : "reject"} <proposal-id>`);
315
+ process.exit(1);
316
+ }
317
+ const { status, json, body } = await _httpJSON(
318
+ "POST",
319
+ `${baseUrl}/api/agent/whoami/approve`,
320
+ token,
321
+ { proposal_id: id, action: isApprove ? "approve" : "reject" },
322
+ );
323
+ if (status !== "200") {
324
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
325
+ process.exit(1);
326
+ }
327
+ console.log(`✓ ${json.status}: ${json.agent} (proposal ${json.proposal_id})`);
328
+ process.exit(0);
329
+ }
330
+
331
+ // plain whoami (no flags)
332
+ const { status, json, body } = await _httpJSON("GET", `${baseUrl}/api/agent/whoami`, token);
333
+ if (status !== "200") {
334
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
335
+ process.exit(1);
336
+ }
337
+ console.log(`agent: ${json.agent}`);
338
+ console.log(`namespace: ${json.namespace}`);
339
+ if (json.project_summary) console.log(`project: ${json.project_summary}`);
340
+ if (json.whoami) {
341
+ console.log(`self: ${json.whoami}`);
342
+ } else {
343
+ console.log(`self: (empty — propose with: patchcord whoami --propose "<text>")`);
344
+ }
345
+ console.log();
346
+ console.log(`tip: \`patchcord agents\` returns whoami for all peers.`);
347
+ process.exit(0);
348
+ }
349
+
350
+ // ── agents [name] ────────────────────────────────────────────
351
+ if (cmd === "agents") {
352
+ const name = (process.argv[3] || "").trim().toLowerCase();
353
+ const found = await _resolveBearer();
354
+ if (!found) {
355
+ console.error("No patchcord config found in current directory or any parent. Run `npx patchcord@latest` from a project directory first.");
356
+ process.exit(1);
357
+ }
358
+ const { token, baseUrl } = found;
359
+ if (!isSafeToken(token) || !isSafeUrl(baseUrl)) {
360
+ console.error(`Invalid patchcord URL or token in config.`);
361
+ process.exit(1);
362
+ }
363
+
364
+ if (name) {
365
+ if (!/^[a-z][a-z0-9-]{0,49}$/.test(name)) {
366
+ console.error("Invalid agent name. Lowercase letters, digits, and dashes only.");
367
+ process.exit(1);
368
+ }
369
+ const { status, json, body } = await _httpJSON("GET", `${baseUrl}/api/agent/whoami/${name}`, token);
370
+ if (status === "404") {
371
+ console.error(`agent '${name}' not found in your namespace.`);
372
+ process.exit(1);
373
+ }
374
+ if (status !== "200") {
375
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
376
+ process.exit(1);
377
+ }
378
+ const tag = json.is_global ? " (global)" : "";
379
+ console.log(`${json.agent}${tag}: ${json.whoami || "(empty)"}`);
380
+ if (json.status || json.last_seen) {
381
+ console.log(`status: ${json.status || "?"} last_seen: ${json.last_seen || "?"}`);
382
+ }
383
+ process.exit(0);
384
+ }
385
+
386
+ const { status, json, body } = await _httpJSON("GET", `${baseUrl}/api/agent/agents`, token);
387
+ if (status !== "200") {
388
+ console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
389
+ process.exit(1);
390
+ }
391
+ if (!json.agents || json.agents.length === 0) {
392
+ console.log("(no agents in this namespace)");
393
+ process.exit(0);
394
+ }
395
+ for (const a of json.agents) {
396
+ const tag = a.is_global ? " (global)" : "";
397
+ const text = a.whoami || "(empty)";
398
+ console.log(`${a.agent}${tag}: ${text}`);
399
+ }
400
+ process.exit(0);
401
+ }
402
+
91
403
  // ── subscribe ─────────────────────────────────────────────────
92
404
  // Thin wrapper around scripts/subscribe.mjs so users see a clean
93
405
  // "npx patchcord subscribe" in their Claude Code tool log instead
@@ -413,18 +725,32 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
413
725
  globalCliInstalled = true;
414
726
  } catch {
415
727
  try {
416
- const stableDir = join(HOME, ".local", "share", "patchcord");
417
- mkdirSync(stableDir, { recursive: true });
418
- cpSync(pluginRoot, stableDir, { recursive: true, force: true });
728
+ const fbStableDir = join(HOME, ".local", "share", "patchcord");
729
+ mkdirSync(fbStableDir, { recursive: true });
730
+ cpSync(pluginRoot, fbStableDir, { recursive: true, force: true });
419
731
  const localBin = join(HOME, ".local", "bin");
420
732
  mkdirSync(localBin, { recursive: true });
421
733
  const wrapper = join(localBin, "patchcord");
422
- writeFileSync(wrapper, `#!/bin/sh\nexec node "${join(stableDir, "bin", "patchcord.mjs")}" "$@"\n`);
734
+ writeFileSync(wrapper, `#!/bin/sh\nexec node "${join(fbStableDir, "bin", "patchcord.mjs")}" "$@"\n`);
423
735
  chmodSync(wrapper, 0o755);
424
736
  globalCliInstalled = true;
425
737
  } catch {}
426
738
  }
427
739
 
740
+ // ── Stable copy for Claude Code plugin marketplace ──────────
741
+ // Claude Code's `plugin marketplace add` stores the literal directory
742
+ // path in its plugin manifest. If we point it at `pluginRoot` (the
743
+ // npx ephemeral cache), that directory gets wiped on the next `npx`
744
+ // invocation of any other package, and every Claude Code session
745
+ // afterward errors: "Plugin directory does not exist: ...".
746
+ // Maintain a stable mirror at ~/.local/share/patchcord/ and point
747
+ // the marketplace there instead.
748
+ const stablePluginDir = join(HOME, ".local", "share", "patchcord");
749
+ try {
750
+ mkdirSync(stablePluginDir, { recursive: true });
751
+ cpSync(pluginRoot, stablePluginDir, { recursive: true, force: true });
752
+ } catch {}
753
+
428
754
  // ── Global setup (silent if nothing changed) ──
429
755
  let globalChanges = [];
430
756
  if (globalCliInstalled) globalChanges.push("Patchcord CLI installed globally");
@@ -438,10 +764,14 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
438
764
  try { rmSync(npmCachePatchcord, { recursive: true, force: true }); } catch {}
439
765
  }
440
766
 
441
- // Always re-add marketplace (copies fresh files from this npx package)
442
- // and install/update plugin. Claude Code's built-in plugin update
443
- // doesn't detect new versions from local sources (#37252).
444
- run(`claude plugin marketplace add "${pluginRoot}"`);
767
+ // Always re-add marketplace (point at the STABLE mirror, not pluginRoot
768
+ // which is the npx ephemeral cache). Claude Code stores the literal
769
+ // path in its plugin manifest pointing at npx cache breaks every
770
+ // future session as soon as that cache dir is swept.
771
+ // Remove any prior marketplace registration first so the path actually
772
+ // updates for users who got the buggy npx-cache path on an earlier install.
773
+ run(`claude plugin marketplace remove patchcord-marketplace`);
774
+ run(`claude plugin marketplace add "${stablePluginDir}"`);
445
775
  const installed = run(`claude plugin list`)?.includes("patchcord");
446
776
  wasPluginInstalled = !!installed;
447
777
  if (installed) {
@@ -1276,8 +1606,12 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1276
1606
  mkdirSync(codexDir, { recursive: true });
1277
1607
  const configPath = join(codexDir, "config.toml");
1278
1608
  let existing = existsSync(configPath) ? readFileSync(configPath, "utf-8") : "";
1279
- // Remove old patchcord config block if present
1280
- existing = existing.replace(/\[mcp_servers\.patchcord[-\w]*\]\n(?:(?!\[)[^\n]*\n?)*/g, "").replace(/\n{3,}/g, "\n\n").trim();
1609
+ // Remove old patchcord config blocks AND any sub-tables like
1610
+ // [mcp_servers.patchcord.http_headers]. The character class [\w.-]
1611
+ // covers patchcord-codex, patchcord.http_headers, etc. We consume
1612
+ // through to the next "[" so the entire block (including subsections)
1613
+ // is removed in one pass.
1614
+ existing = existing.replace(/\[mcp_servers\.patchcord[\w.-]*\][^\[]*/g, "").replace(/\n{3,}/g, "\n\n").trim();
1281
1615
  existing = existing.trimEnd() + `\n\n[mcp_servers.patchcord-codex]\nurl = "${serverUrl}/mcp"\nhttp_headers = { "Authorization" = "Bearer ${token}", "X-Patchcord-Machine" = "${hostname}" }\ntool_timeout_sec = 300\n`;
1282
1616
  writeFileSync(configPath, existing);
1283
1617
  // Clean up any PATCHCORD_TOKEN we previously wrote to .env
@@ -1605,5 +1939,5 @@ if (cmd === "skill") {
1605
1939
  process.exit(0);
1606
1940
  }
1607
1941
 
1608
- console.error(`Unknown command: ${cmd}. Available: subscribe, --rename, --token, --tool`);
1942
+ console.error(`Unknown command: ${cmd}. Available: whoami, agents, subscribe, update, --rename, --token, --agent-type, --version, --help`);
1609
1943
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.5.54",
3
+ "version": "0.5.56",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -112,6 +112,29 @@ Use the path from the sender's message.
112
112
 
113
113
  Send the returned `path` to the other agent in your message so they can download it.
114
114
 
115
+ ## Identity (`patchcord whoami` / `patchcord agents`)
116
+
117
+ `whoami` and `agents` are CLI commands, not MCP tools. They use the bearer token from the project's `.mcp.json` — same namespace scope, no extra setup. Cheap to call (input tokens only), don't bloat MCP.
118
+
119
+ - **Run `patchcord whoami` once per session.** Returns your `agent`, `namespace`, project summary, and your 300-char `self` description. Use it on first turn after `/clear` or a fresh session to orient.
120
+ - **Run `patchcord agents`** to see the full roster (every peer's whoami). One call, ~3KB, complete picture of the namespace.
121
+ - **Run `patchcord agents <name>`** when an unknown agent messages you and you want to know who they are before acting on their request.
122
+
123
+ ### Updating your own whoami (gated)
124
+
125
+ 300-char hard limit. Goes through a human-approval flow.
126
+
127
+ - `patchcord whoami --propose "<text>"` — submits a pending proposal. Returns a `proposal_id`. The server also creates a normal patchcord message to `human` showing the diff.
128
+ - Another agent (NOT you) or the human via dashboard must approve. Self-approval is blocked server-side — you can propose updates to your own whoami, but you cannot approve them.
129
+ - To approve someone else's proposal: `patchcord whoami --approve <proposal-id>`. To reject: `patchcord whoami --reject <id>`.
130
+ - The human sees your proposal in their inbox/dashboard. They will approve when ready. Do not retry — proposals are idempotent.
131
+
132
+ ### Hard rules
133
+
134
+ - You may NEVER update another agent's whoami. The `--propose` flow only writes your own.
135
+ - Namespace scope is enforced server-side: `patchcord agents <name>` returns 404 if the name isn't in your namespace (global agents like claudeai/chatgpt are excepted on cloud).
136
+ - whoami text describes WHO you are and how you coordinate (e.g. "backend systems. sends every change to codex-backend for review"). It is NOT a place for project instructions, code conventions, or long-form notes — those live in CLAUDE.md and project docs.
137
+
115
138
  ## Threads
116
139
 
117
140
  Named threads group related messages between a pair of agents. Use them for multi-turn tasks that need their own context (e.g. "auth-migration", "deploy-review").