patchcord 0.5.31 → 0.5.33

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
@@ -67,7 +67,7 @@ Usage:
67
67
  npx patchcord@latest --token <token> Self-hosted / CI setup
68
68
  npx patchcord@latest --token <token> --server <url> Self-hosted with custom server
69
69
  npx patchcord@latest --full Same + full statusline
70
- npx patchcord@latest --rename <new-name> Rename this agent (paste from dashboard)
70
+ npx patchcord@latest --rename <new-name> [--tool <slug>] Rename this agent (paste from dashboard)
71
71
  npx patchcord@latest skill apply Fetch custom skill from web console`);
72
72
  process.exit(0);
73
73
  }
@@ -77,47 +77,110 @@ if (cmd === "plugin-path") {
77
77
  process.exit(0);
78
78
  }
79
79
 
80
- // ── --rename <new> [--expect-token <prefix>] ────────────────────
81
- // Renames the agent in cwd's .mcp.json. Dashboard generates the full
82
- // pasted command including --expect-token to prevent wrong-machine paste.
80
+ // ── --rename <new> [--tool <slug>] [--expect-token <prefix>] ────
81
+ // Renames the agent's bearer in its tool's per-project config. Supported
82
+ // tools: claude_code, codex, cursor, vscode, opencode. Dashboard generates
83
+ // the full pasted command including --tool (so we read the right file)
84
+ // and --expect-token (sha256 prefix, prevents wrong-machine paste).
83
85
  {
84
86
  const renameIdx = process.argv.indexOf("--rename");
85
87
  if (renameIdx !== -1) {
86
88
  const newName = (process.argv[renameIdx + 1] || "").trim().toLowerCase();
87
89
  const expectIdx = process.argv.indexOf("--expect-token");
88
90
  const expectPrefix = expectIdx !== -1 ? (process.argv[expectIdx + 1] || "").trim() : "";
91
+ const toolIdx = process.argv.indexOf("--tool");
92
+ const tool = toolIdx !== -1 ? (process.argv[toolIdx + 1] || "").trim().toLowerCase() : "";
89
93
 
90
94
  if (!newName || !/^[a-z][a-z0-9-]{1,49}$/.test(newName)) {
91
- console.error("Usage: npx patchcord@latest --rename <new-name> [--expect-token <prefix>]");
92
- console.error(" <new-name> must be lowercase letters, digits, dashes; 2-50 chars");
95
+ console.error("Usage: npx patchcord@latest --rename <new-name> [--tool <slug>] [--expect-token <prefix>]");
96
+ console.error(" <new-name>: lowercase letters, digits, dashes; 2-50 chars");
97
+ console.error(" <slug>: claude_code | codex | cursor | vscode | opencode");
93
98
  process.exit(1);
94
99
  }
95
100
 
96
- const cwd = process.cwd();
97
- let mcpJson = null;
98
- let dir = cwd;
99
- while (dir && dir !== "/") {
100
- const p = join(dir, ".mcp.json");
101
- if (existsSync(p)) { mcpJson = p; break; }
102
- dir = dirname(dir);
103
- }
104
- if (!mcpJson) {
105
- console.error(`No .mcp.json found above ${cwd}. Run from your patchcord project folder.`);
101
+ const { readFileSync } = await import("fs");
102
+
103
+ // Per-tool token readers. Each returns {token, baseUrl, configFile} or null.
104
+ const readMcpJsonShape = (path, key = "mcpServers") => {
105
+ if (!existsSync(path)) return null;
106
+ try {
107
+ const obj = JSON.parse(readFileSync(path, "utf-8"));
108
+ const pt = obj?.[key]?.patchcord;
109
+ if (!pt?.headers?.Authorization || !pt?.url) return null;
110
+ return {
111
+ token: pt.headers.Authorization.replace(/^Bearer\s+/i, ""),
112
+ baseUrl: pt.url.replace(/\/mcp(\/bearer)?$/, ""),
113
+ configFile: path,
114
+ };
115
+ } catch { return null; }
116
+ };
117
+ const readOpenCodeShape = (path) => {
118
+ if (!existsSync(path)) return null;
119
+ try {
120
+ const obj = JSON.parse(readFileSync(path, "utf-8"));
121
+ const pt = obj?.mcp?.patchcord;
122
+ if (!pt?.headers?.Authorization || !pt?.url) return null;
123
+ return {
124
+ token: pt.headers.Authorization.replace(/^Bearer\s+/i, ""),
125
+ baseUrl: pt.url.replace(/\/mcp(\/bearer)?$/, ""),
126
+ configFile: path,
127
+ };
128
+ } catch { return null; }
129
+ };
130
+ const readCodexTomlShape = (path) => {
131
+ if (!existsSync(path)) return null;
132
+ try {
133
+ const content = readFileSync(path, "utf-8");
134
+ const block = content.match(/\[mcp_servers\.patchcord[-\w]*\]([\s\S]*?)(?=\n\[|$)/);
135
+ if (!block) return null;
136
+ const urlMatch = block[1].match(/url\s*=\s*"([^"]+)"/);
137
+ const tokenMatch = block[1].match(/Bearer\s+([^\s"]+)/);
138
+ if (!urlMatch || !tokenMatch) return null;
139
+ return {
140
+ token: tokenMatch[1],
141
+ baseUrl: urlMatch[1].replace(/\/mcp(\/bearer)?$/, ""),
142
+ configFile: path,
143
+ };
144
+ } catch { return null; }
145
+ };
146
+
147
+ const READERS = {
148
+ claude_code: (cwd) => readMcpJsonShape(join(cwd, ".mcp.json")),
149
+ cursor: (cwd) => readMcpJsonShape(join(cwd, ".cursor", "mcp.json")),
150
+ vscode: (cwd) => readMcpJsonShape(join(cwd, ".vscode", "mcp.json"), "servers"),
151
+ opencode: (cwd) => readOpenCodeShape(join(cwd, "opencode.json")),
152
+ codex: (cwd) => readCodexTomlShape(join(cwd, ".codex", "config.toml")),
153
+ };
154
+
155
+ if (tool && !READERS[tool]) {
156
+ console.error(`✗ Rename not supported for --tool=${tool}.`);
157
+ console.error(` Supported: ${Object.keys(READERS).join(", ")}.`);
158
+ console.error(` This usually means the tool stores its config in a global location`);
159
+ console.error(` not yet wired up to --rename. Open an issue.`);
106
160
  process.exit(1);
107
161
  }
108
162
 
109
- const { readFileSync } = await import("fs");
110
- let config;
111
- try {
112
- config = JSON.parse(readFileSync(mcpJson, "utf-8"));
113
- } catch (e) {
114
- console.error(`Cannot parse ${mcpJson}: ${e.message}`);
163
+ // Walk up from cwd looking for any supported config. If --tool given,
164
+ // only check that tool; otherwise try all in priority order.
165
+ const cwd = process.cwd();
166
+ const tryReaders = tool ? [READERS[tool]] : Object.values(READERS);
167
+ let found = null;
168
+ let dir = cwd;
169
+ while (dir && dir !== "/" && !found) {
170
+ for (const r of tryReaders) {
171
+ const result = r(dir);
172
+ if (result) { found = result; break; }
173
+ }
174
+ if (!found) dir = dirname(dir);
175
+ }
176
+ if (!found) {
177
+ const whichFile = tool
178
+ ? `the ${tool} config file in or above ${cwd}`
179
+ : `any supported tool config (.mcp.json, .cursor/mcp.json, .vscode/mcp.json, opencode.json, .codex/config.toml) above ${cwd}`;
180
+ console.error(`No patchcord bearer found in ${whichFile}. Run from the agent's project folder.`);
115
181
  process.exit(1);
116
182
  }
117
- const mcpUrl = config?.mcpServers?.patchcord?.url || "";
118
- const auth = config?.mcpServers?.patchcord?.headers?.Authorization || "";
119
- const baseUrl = mcpUrl.replace(/\/mcp(\/bearer)?$/, "");
120
- const token = auth.replace(/^Bearer\s+/, "");
183
+ const { token, baseUrl, configFile: mcpJson } = found;
121
184
 
122
185
  if (!baseUrl || !token || !isSafeToken(token) || !isSafeUrl(baseUrl)) {
123
186
  console.error(`Cannot read patchcord URL/token from ${mcpJson}.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.5.31",
3
+ "version": "0.5.33",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -23,19 +23,27 @@ const RECONNECT_BACKOFF_MS = [1000, 2000, 4000, 8000, 15_000, 30_000];
23
23
  const FRESHNESS_CHECK_INTERVAL_MS = 30_000;
24
24
  const FRESHNESS_STALE_MS = 90_000;
25
25
 
26
+ // Short HH:MM:SS prefix so the Monitor output can be scanned at a glance.
27
+ // Local time — Monitor's reader is always a human looking at one machine.
28
+ function logErr(msg) {
29
+ const d = new Date();
30
+ const ts = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
31
+ process.stderr.write(`[${ts}] ${msg}\n`);
32
+ }
33
+
26
34
  // Guarantee a terminal stderr line on any unhandled failure so the agent
27
35
  // reading Monitor's output file always sees WHY the process died.
28
36
  process.on("uncaughtException", (err) => {
29
- process.stderr.write(`subscribe: fatal: uncaught: ${err?.stack || err?.message || err}\n`);
37
+ logErr(`subscribe: fatal: uncaught: ${err?.stack || err?.message || err}`);
30
38
  process.exit(1);
31
39
  });
32
40
  process.on("unhandledRejection", (err) => {
33
- process.stderr.write(`subscribe: fatal: unhandled rejection: ${err?.stack || err?.message || err}\n`);
41
+ logErr(`subscribe: fatal: unhandled rejection: ${err?.stack || err?.message || err}`);
34
42
  process.exit(1);
35
43
  });
36
44
 
37
45
  function die(msg, code = 1) {
38
- process.stderr.write(msg + "\n");
46
+ logErr(msg);
39
47
  process.exit(code);
40
48
  }
41
49
 
@@ -178,7 +186,7 @@ function removePidfile(path) {
178
186
  async function run() {
179
187
  const cwd = process.cwd();
180
188
  const { baseUrl, token } = readMcpConfig(cwd);
181
- process.stderr.write(`subscribe: cwd=${cwd} server=${baseUrl}\n`);
189
+ logErr(`subscribe: cwd=${cwd} server=${baseUrl}`);
182
190
 
183
191
  let ticket = await fetchTicket(baseUrl, token);
184
192
  const pidfile = `/tmp/patchcord_subscribe_${ticket.namespace_ids[0]}_${ticket.agent_id}.pid`;
@@ -195,9 +203,7 @@ async function run() {
195
203
  process.exit(0);
196
204
  });
197
205
 
198
- process.stderr.write(
199
- `subscribe: agent=${ticket.agent_id} namespaces=${ticket.namespace_ids.join(",")}\n`
200
- );
206
+ logErr(`subscribe: agent=${ticket.agent_id} namespaces=${ticket.namespace_ids.join(",")}`);
201
207
 
202
208
  let backoffIdx = 0;
203
209
 
@@ -210,16 +216,16 @@ async function run() {
210
216
  });
211
217
  backoffIdx = 0; // clean disconnect resets backoff
212
218
  } catch (e) {
213
- process.stderr.write(`subscribe: ${e.message}\n`);
219
+ logErr(`subscribe: ${e.message}`);
214
220
  }
215
221
  const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
216
222
  backoffIdx++;
217
- process.stderr.write(`subscribe: reconnecting in ${delay}ms\n`);
223
+ logErr(`subscribe: reconnecting in ${delay}ms`);
218
224
  await new Promise((r) => setTimeout(r, delay));
219
225
  try {
220
226
  ticket = await fetchTicket(baseUrl, token);
221
227
  } catch (e) {
222
- process.stderr.write(`subscribe: ticket refresh failed: ${e.message}\n`);
228
+ logErr(`subscribe: ticket refresh failed: ${e.message}`);
223
229
  }
224
230
  }
225
231
  };
@@ -255,7 +261,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
255
261
  };
256
262
 
257
263
  ws.on("open", () => {
258
- process.stderr.write("subscribe: connected\n");
264
+ logErr("subscribe: connected");
259
265
  for (const topic of ticket.topics) {
260
266
  ws.send(
261
267
  JSON.stringify({
@@ -277,7 +283,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
277
283
  // failure here just means we miss queued messages this round;
278
284
  // the next reconnect retries.
279
285
  drainQueueOnce(baseUrl, token).catch((e) => {
280
- process.stderr.write(`subscribe: queue check failed: ${e.message}\n`);
286
+ logErr(`subscribe: queue check failed: ${e.message}`);
281
287
  });
282
288
  heartbeatTimer = setInterval(() => {
283
289
  try {
@@ -300,9 +306,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
300
306
  freshnessTimer = setInterval(() => {
301
307
  const stale = Date.now() - lastEventAt;
302
308
  if (stale > FRESHNESS_STALE_MS) {
303
- process.stderr.write(
304
- `subscribe: ws stale ${Math.round(stale / 1000)}s, forcing reconnect\n`
305
- );
309
+ logErr(`subscribe: ws stale ${Math.round(stale / 1000)}s, forcing reconnect`);
306
310
  done(new Error(`ws stale ${Math.round(stale / 1000)}s`));
307
311
  }
308
312
  }, FRESHNESS_CHECK_INTERVAL_MS);
@@ -340,13 +344,16 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
340
344
  })
341
345
  );
342
346
  }
343
- process.stderr.write("subscribe: token refreshed\n");
347
+ // Routine refresh success — don't log. Over days this fills the
348
+ // Monitor output with ~250 identical lines and buries the rare
349
+ // interesting events (connects, errors). Refresh failures DO log,
350
+ // because those are the ones worth scanning for.
344
351
  scheduleRefresh(fresh.jwt_expires_in);
345
352
  } catch (e) {
346
353
  // Transient network/server error — do NOT close the live
347
354
  // connection. The current JWT is still valid for ~2 more min
348
355
  // (JWT_REFRESH_SAFETY_MARGIN_SEC). Retry sooner.
349
- process.stderr.write(`subscribe: token refresh failed, retrying in 30s: ${e.message}\n`);
356
+ logErr(`subscribe: token refresh failed, retrying in 30s: ${e.message}`);
350
357
  refreshTimer = setTimeout(doRefresh, 30_000);
351
358
  }
352
359
  };
@@ -384,20 +391,20 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
384
391
  });
385
392
 
386
393
  ws.on("error", (err) => {
387
- process.stderr.write(`subscribe: ws error: ${err.message}\n`);
394
+ logErr(`subscribe: ws error: ${err.message}`);
388
395
  done(err);
389
396
  });
390
397
 
391
398
  ws.on("close", (info) => {
392
399
  const codeStr = info?.code != null ? `code=${info.code}` : "code=none";
393
400
  const reasonStr = info?.reason ? ` reason=${JSON.stringify(info.reason)}` : "";
394
- process.stderr.write(`subscribe: ws closed (${codeStr}${reasonStr})\n`);
401
+ logErr(`subscribe: ws closed (${codeStr}${reasonStr})`);
395
402
  done();
396
403
  });
397
404
  });
398
405
  }
399
406
 
400
407
  run().catch((e) => {
401
- process.stderr.write(`subscribe: fatal: ${e.message}\n`);
408
+ logErr(`subscribe: fatal: ${e.message}`);
402
409
  process.exit(1);
403
410
  });