patchcord 0.6.29 → 0.6.31

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "patchcord",
3
3
  "description": "Cross-machine agent messaging. Messages from other agents land in the inbox and wake the agent to reply.",
4
- "version": "0.6.29",
4
+ "version": "0.6.31",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
package/bin/patchcord.mjs CHANGED
@@ -12,6 +12,7 @@ const HOME = homedir();
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
13
  const pluginRoot = join(__dirname, "..");
14
14
  import { resolveProjectBearer, harnessContext } from "../scripts/lib/resolve-project-bearer.mjs";
15
+ import { detectClaudeLocalMcpOverride } from "../scripts/lib/claude-local-mcp.mjs";
15
16
  const cmd = process.argv[2];
16
17
 
17
18
  /** Write a file then chmod 0600 (token-bearing configs must not be group/world readable). */
@@ -167,7 +168,7 @@ account:
167
168
  patchcord login authenticate the CLI (your account)
168
169
 
169
170
  orchestrator:
170
- patchcord orchestrator [--namespace <ns>] [--tool <harness>] [--project <name>]
171
+ patchcord orchestrator --tool <harness> [--namespace <ns>] [--project <name>]
171
172
  provision the orchestrator here (always
172
173
  a lead); launch it to adopt this project
173
174
  or build a new team
@@ -180,6 +181,12 @@ team (run from the project root):
180
181
  place an existing agent into a folder
181
182
  (same identity; supersedes prior token —
182
183
  only ONE live credential per identity)
184
+
185
+ --tool is REQUIRED on provision / pull / orchestrator. It names which harness
186
+ config to write, and therefore WHICH AGENT. A folder may hold one patchcord
187
+ config per harness (.mcp.json for claude_code, .cursor/mcp.json for cursor, …)
188
+ and those can be different identities. Only the named harness is written —
189
+ sibling harness configs are never touched.
183
190
  patchcord provision revoke <agent> --namespace <ns>
184
191
  revoke a worker agent
185
192
  patchcord team list [--namespace <ns>] [--json]
@@ -189,11 +196,24 @@ team (run from the project root):
189
196
  patchcord team blueprint push --namespace <ns> --file <blueprint.json> [--json]
190
197
  create/update a team blueprint (first push
191
198
  to a new namespace claims it — first-provision)
199
+ PROMOTES BY DEFAULT — the pushed document
200
+ goes live immediately
201
+ patchcord team blueprint push ... --no-promote (alias --dry-run)
202
+ validate + store WITHOUT going live. Use this
203
+ when any deployer may not yet understand a
204
+ value in the document.
192
205
  patchcord team deployment request --namespace <ns> [--revision <uid>] [--json]
193
206
  enqueue an apply of a saved revision
194
207
  (exit 3 = one is already in flight)
195
208
  patchcord team deployment pending [--json] jobs waiting for this host to claim
196
- patchcord team deployment claim --job <uid> [--installation <name>] [--json]
209
+ patchcord team deployment claim --job <uid> [--installation <name>]
210
+ [--installation-uuid <uuid>] [--json]
211
+ --installation is the host LABEL
212
+ (becomes claimed_by).
213
+ --installation-uuid is the identity
214
+ selector; must match the enrolment
215
+ body or identity-key membership is
216
+ never recorded. Optional.
197
217
  take ownership (exit 3 = lost the race)
198
218
  patchcord team deployment complete --job <uid> --status <healthy|degraded|cancelled>
199
219
  [--result <json> | --result-file <path>] [--json]
@@ -721,6 +741,14 @@ if (cmd === "whoami") {
721
741
  console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
722
742
  process.exit(1);
723
743
  }
744
+ // Claude Code's local MCP cache can shadow .mcp.json with a stale bearer,
745
+ // which is exactly the state in which an agent reaches for whoami: the CLI
746
+ // works, the MCP tools 401, and the error blames the token. Read-only; see
747
+ // scripts/lib/claude-local-mcp.mjs.
748
+ const warnings = [];
749
+ const localOverride = detectClaudeLocalMcpOverride({ cwd: process.cwd(), diskToken: token });
750
+ if (localOverride) warnings.push(localOverride);
751
+
724
752
  if (wantJson) {
725
753
  const out = {
726
754
  agent: json.agent,
@@ -730,6 +758,7 @@ if (cmd === "whoami") {
730
758
  tool: found.tool || null,
731
759
  config_file: found.configFile || null,
732
760
  scope: found.scope || null,
761
+ warnings,
733
762
  };
734
763
  process.stdout.write(JSON.stringify(out) + "\n");
735
764
  process.exit(0);
@@ -747,6 +776,14 @@ if (cmd === "whoami") {
747
776
  }
748
777
  console.log();
749
778
  console.log(`tip: \`patchcord agents\` returns whoami for all peers.`);
779
+ for (const w of warnings) {
780
+ // stderr, matching the global-scope warning above. The line is addressed
781
+ // to the AGENT and says to relay it, because the human has no reason to
782
+ // know this command exists and will only ever see the 401.
783
+ console.error();
784
+ console.error(`\x1b[33m⚠ ${w.code}`);
785
+ console.error(` TELL THE HUMAN: ${w.tell_human}\x1b[0m`);
786
+ }
750
787
  process.exit(0);
751
788
  }
752
789
 
@@ -1246,42 +1283,34 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1246
1283
  return { ...r, m };
1247
1284
  };
1248
1285
 
1249
- /** After reminting into one harness file, update OTHER existing project
1250
- * patchcord configs in the same dir so subscribe/hooks don't keep a
1251
- * superseded pct- (dead .mcp.json next to live .cursor/mcp.json). */
1252
- const syncSiblingPatchcordTokens = (dir, baseUrl, token, hostname, primaryTool) => {
1253
- const hdr = { Authorization: `Bearer ${token}`, "X-Patchcord-Machine": hostname };
1254
- const touchJson = (path, mutate) => {
1255
- if (!existsSync(path)) return;
1256
- try {
1257
- let obj = {};
1258
- try { obj = JSON.parse(readFileSync(path, "utf-8")); } catch { return; }
1259
- // Only rewrite if a patchcord entry already exists — never invent configs.
1260
- const before = JSON.stringify(obj);
1261
- mutate(obj);
1262
- if (JSON.stringify(obj) === before) return;
1263
- writeSecureFile(path, JSON.stringify(obj, null, 2) + "\n");
1264
- } catch {}
1265
- };
1266
- // Claude Code project config
1267
- if (primaryTool !== "claude_code") {
1268
- touchJson(join(dir, ".mcp.json"), (o) => {
1269
- if (!o.mcpServers?.patchcord) return;
1270
- o.mcpServers.patchcord = {
1271
- ...(o.mcpServers.patchcord.type ? { type: o.mcpServers.patchcord.type } : { type: "http" }),
1272
- url: `${baseUrl}/mcp/bearer`,
1273
- headers: hdr,
1274
- };
1275
- });
1276
- }
1277
- // Cursor project config
1278
- if (primaryTool !== "cursor") {
1279
- touchJson(join(dir, ".cursor", "mcp.json"), (o) => {
1280
- if (!o.mcpServers?.patchcord) return;
1281
- o.mcpServers.patchcord = { url: `${baseUrl}/mcp/bearer`, headers: hdr };
1282
- });
1283
- }
1284
- };
1286
+ // ── ONE HARNESS PER COMMAND no sibling writes, ever ──
1287
+ //
1288
+ // This file used to run `syncSiblingPatchcordTokens` after every write:
1289
+ // remint into the harness named by --tool, then stamp that same bearer into
1290
+ // every OTHER patchcord config in the directory. The stated reason was to
1291
+ // avoid leaving a superseded pct- beside a live one, and that reason only
1292
+ // holds if the sibling files are copies of ONE identity.
1293
+ //
1294
+ // They routinely are not. The product rule is: at most one patchcord config
1295
+ // PER HARNESS in a folder, and a folder MAY hold several harnesses at once —
1296
+ // and those may be DIFFERENT agents:
1297
+ //
1298
+ // .mcp.json -> lead@mux-v2 (claude_code)
1299
+ // .cursor/mcp.json -> mux-cursor@mux (cursor)
1300
+ //
1301
+ // There is no "one directory, one shared token" rule and there never was. So
1302
+ // `patchcord pull mux-cursor --tool cursor` was silently overwriting another
1303
+ // agent's live credential — an identity never named on the command line —
1304
+ // and breaking Claude's MCP when only Cursor had been asked for. It burned a
1305
+ // production team.
1306
+ //
1307
+ // The rule now, with no exception and no opt-in flag: WRITE ONLY THE CONFIG
1308
+ // FOR THE HARNESS NAMED BY --tool. A same-identity sync would still have to
1309
+ // prove the sibling is the same agent, and the case it most wants to fix —
1310
+ // a sibling whose token is already dead — is exactly the case that cannot be
1311
+ // attributed: "my own stale copy" and "another agent's stale copy" look
1312
+ // identical from here. Re-pull that harness by name instead; it is
1313
+ // unambiguous, and it names the identity being changed.
1285
1314
 
1286
1315
  const writeWorkerConfig = (tool, dir, baseUrl, token, hostname) => {
1287
1316
  mkdirSync(dir, { recursive: true });
@@ -1344,7 +1373,8 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1344
1373
  headers: hdr,
1345
1374
  };
1346
1375
  });
1347
- syncSiblingPatchcordTokens(dir, baseUrl, token, hostname, tool);
1376
+ // No sibling writes. `--tool cursor` touches cursor's config and nothing
1377
+ // else; .mcp.json in this directory may be a different agent entirely.
1348
1378
  return written;
1349
1379
  }
1350
1380
  if (tool === "antigravity" || tool === "agy") {
@@ -1388,10 +1418,34 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1388
1418
  o.mcpServers = o.mcpServers || {};
1389
1419
  o.mcpServers.patchcord = { type: "http", url: `${baseUrl}/mcp`, headers: hdr };
1390
1420
  });
1391
- syncSiblingPatchcordTokens(dir, baseUrl, token, hostname, tool);
1421
+ // No sibling writes. .cursor/mcp.json in this directory may hold a
1422
+ // different agent's live credential.
1392
1423
  return primary;
1393
1424
  };
1394
1425
 
1426
+ /** --tool is REQUIRED on anything that writes a patchcord MCP config.
1427
+ *
1428
+ * It used to default to claude_code, and that default was a guess about
1429
+ * WHICH AGENT to overwrite. In a folder holding one config per harness —
1430
+ * each possibly a different identity — silently choosing claude_code means
1431
+ * `patchcord pull <agent>` rewrites `.mcp.json` and whatever identity lived
1432
+ * there, without that identity appearing anywhere on the command line.
1433
+ * Refusing costs one flag; guessing costs someone's live credential. */
1434
+ const requireToolFlag = (usage) => {
1435
+ const raw = (flagVal("tool", "") || "").trim();
1436
+ if (raw) return raw;
1437
+ console.error(`patchcord: --tool is required.`);
1438
+ console.error(` It names which harness config to write — and therefore which agent.`);
1439
+ console.error(` One folder may hold one patchcord config per harness, and they can be`);
1440
+ console.error(` DIFFERENT identities:`);
1441
+ console.error(` .mcp.json → claude_code`);
1442
+ console.error(` .cursor/mcp.json → cursor`);
1443
+ console.error(` Defaulting would overwrite an agent you did not name, so there is no default.`);
1444
+ console.error(` One of: claude_code, codex, cursor, kimi, opencode, antigravity, grok, hermes`);
1445
+ console.error(` ${usage}`);
1446
+ process.exit(1);
1447
+ };
1448
+
1395
1449
  if (cmd === "login") {
1396
1450
  // Explicit CLI login. Other commands log in on demand via requireAuth(),
1397
1451
  // so this is only needed to authenticate ahead of time or switch accounts.
@@ -1411,7 +1465,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1411
1465
  console.log(`✓ revoked ${ns}:${ra} (${json.count} token(s))`);
1412
1466
  process.exit(0);
1413
1467
  }
1414
- const tool = flagVal("tool", "claude_code");
1468
+ const tool = requireToolFlag("patchcord provision <agent> --tool X --role Y --namespace ns [--dir sub/] [--project name] [--lead]");
1415
1469
  const role = flagVal("role", "");
1416
1470
  const ns = flagVal("namespace");
1417
1471
  const subdir = flagVal("dir", arg);
@@ -1500,7 +1554,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1500
1554
  // symmetry only; server is source of truth.
1501
1555
  const arg = process.argv[3];
1502
1556
  if (!arg || arg.startsWith("-")) { console.error("Usage: patchcord pull <agent> --namespace ns --tool X [--dir sub/] [--lead]"); process.exit(1); }
1503
- const tool = flagVal("tool", "claude_code");
1557
+ const tool = requireToolFlag("patchcord pull <agent> --tool X --namespace ns [--dir sub/] [--lead]");
1504
1558
  const ns = flagVal("namespace");
1505
1559
  const subdir = flagVal("dir", arg);
1506
1560
  const lead = process.argv.includes("--lead"); // typed symmetry; server ignores escalate-on-pull
@@ -1537,7 +1591,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1537
1591
  // (`teamlead` is kept as a back-compat alias for the command name.)
1538
1592
  const root = process.cwd();
1539
1593
  const ns = (flagVal("namespace", basename(root)) || "team").replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1540
- const tool = flagVal("tool", "claude_code");
1594
+ const tool = requireToolFlag("patchcord orchestrator --tool X [--namespace ns] [--project name]");
1541
1595
  const project = flagVal("project", "");
1542
1596
  const hostname = run("hostname -s") || run("hostname") || "unknown";
1543
1597
  // is_lead: true always — the orchestrator IS the one identity per namespace
@@ -1660,13 +1714,27 @@ you design the team, provision its agents, launch them, and manage them.
1660
1714
  const file = flagVal("file");
1661
1715
  const idem = flagVal("idempotency-key");
1662
1716
  const wantJson = process.argv.includes("--json");
1663
- if (!ns || !file) { console.error("Usage: patchcord team blueprint push --namespace <ns> --file <blueprint.json> [--idempotency-key k] [--json]"); process.exit(1); }
1717
+ // --no-promote / --dry-run: store the revision and run strict server
1718
+ // validation WITHOUT moving the live pointer.
1719
+ //
1720
+ // This exists because push promotes by default, so a hand-written
1721
+ // document goes live the instant it validates. That is fine when the
1722
+ // document is one every consumer understands, and it is how a Team gets
1723
+ // bricked when it is not: the server can accept a value that deployers
1724
+ // do not yet handle, and promoting it makes the Team undeployable AND
1725
+ // unrestorable from one command. The rule the team arrived at is
1726
+ // "consumers accept before producers emit", and until now the only way
1727
+ // to honour it here was to remember to. This is the safe path existing
1728
+ // rather than being agreed.
1729
+ const noPromote = process.argv.includes("--no-promote") || process.argv.includes("--dry-run");
1730
+ if (!ns || !file) { console.error("Usage: patchcord team blueprint push --namespace <ns> --file <blueprint.json> [--no-promote|--dry-run] [--idempotency-key k] [--json]"); process.exit(1); }
1664
1731
  let doc;
1665
1732
  try { doc = JSON.parse(readFileSync(file, "utf-8")); }
1666
1733
  catch (e) { console.error(`cannot read blueprint file: ${e.message}`); process.exit(1); }
1667
1734
  // First POST for an unowned namespace IS first-provision (server claims
1668
1735
  // the namespace atomically when PATCHCORD_BLUEPRINT_ATOMIC_CREATE is on).
1669
1736
  const payload = { canonical_json: doc };
1737
+ if (noPromote) payload.promote = false;
1670
1738
  if (idem) payload.idempotency_key = idem;
1671
1739
  const { status, json, body } = await accountCall("POST", `/api/team/${encodeURIComponent(ns)}/blueprint/revisions`, payload);
1672
1740
  if (status !== "201" && status !== "200") {
@@ -1674,7 +1742,7 @@ you design the team, provision its agents, launch them, and manage them.
1674
1742
  process.exit(1);
1675
1743
  }
1676
1744
  if (wantJson) {
1677
- process.stdout.write(JSON.stringify({ ...json, created: status === "201" }) + "\n");
1745
+ process.stdout.write(JSON.stringify({ ...json, created: status === "201", promoted: json.promoted !== false && !noPromote }) + "\n");
1678
1746
  process.exit(0);
1679
1747
  }
1680
1748
  console.log(`${status === "201" ? "created" : "unchanged (dedup)"} revision_uid: ${json.revision_uid}`);
@@ -1744,10 +1812,35 @@ you design the team, provision its agents, launch them, and manage them.
1744
1812
  if (dsub === "claim") {
1745
1813
  const job = flagVal("job");
1746
1814
  const installation = flagVal("installation");
1747
- if (!job) { console.error("Usage: patchcord team deployment claim --job <uid> [--installation <name>] [--json]"); process.exit(1); }
1815
+ // SEPARATE FIELD, deliberately not a new meaning for --installation.
1816
+ //
1817
+ // --installation becomes claimed_by, which is the human-readable "which
1818
+ // host took this job" an operator reads in the deploy list. Putting a
1819
+ // UUID there would spend a label field on an identifier and leave
1820
+ // nothing legible in its place.
1821
+ //
1822
+ // They are also different things by construction: --installation is
1823
+ // <hostname>-<suffix>, live (macOS renames itself on a Bonjour
1824
+ // collision) and MUX_HOST_ID-overridable — the exact selector we
1825
+ // disqualified for the identity flow. --installation-uuid is minted
1826
+ // beside the identity key itself so the key and its selector share one
1827
+ // lifetime. It is what the server keys team membership on, so it must
1828
+ // match the enrolment body's installation_uuid EXACTLY or membership is
1829
+ // written against a string nothing else uses and the team-scoped read
1830
+ // returns an empty set forever.
1831
+ //
1832
+ // Omitting it is legal and stays legal: an installation that has not
1833
+ // upgraded keeps claiming exactly as before and simply records no
1834
+ // identity-key membership. The server has accepted this field since
1835
+ // bb553b2, so consumers accept before producers emit.
1836
+ const installationUuid = flagVal("installation-uuid");
1837
+ if (!job) { console.error("Usage: patchcord team deployment claim --job <uid> [--installation <name>] [--installation-uuid <uuid>] [--json]"); process.exit(1); }
1838
+ const claimBody = {};
1839
+ if (installation) claimBody.installation = installation;
1840
+ if (installationUuid) claimBody.installation_uuid = installationUuid;
1748
1841
  const { status, json, body } = await accountCall(
1749
1842
  "POST", `/api/deployments/${encodeURIComponent(job)}/claim`,
1750
- installation ? { installation } : {},
1843
+ claimBody,
1751
1844
  );
1752
1845
  // Losing a claim race is normal in a multi-host account, not a failure:
1753
1846
  // exit 3 so a poller can skip to the next job without parsing stderr.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.29",
3
+ "version": "0.6.31",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "scripts": {
6
6
  "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin/plugin.json"
@@ -0,0 +1,131 @@
1
+ // Detect Claude Code's LOCAL MCP config cache shadowing the project .mcp.json.
2
+ //
3
+ // Claude Code stores a per-project MCP config in ~/.claude.json under
4
+ // projects[<abs project dir>].mcpServers. That is the "local" scope, and
5
+ // Claude Code's precedence is:
6
+ //
7
+ // enterprise > LOCAL > project (.mcp.json) > user
8
+ //
9
+ // So a stale bearer cached there BEATS the live one on disk. The failure is
10
+ // nasty because the two halves disagree about who is right:
11
+ //
12
+ // `patchcord whoami` reads .mcp.json -> works, identity looks healthy
13
+ // Claude's MCP client reads the cache -> 401 "requires re-authorization"
14
+ //
15
+ // An agent hitting that 401 concludes its token expired, and it has not. The
16
+ // credential is fine; only Claude's MCP client is poisoned. Nothing in the
17
+ // error says so, and the human has no reason to know `whoami` exists — which
18
+ // is why this check reports to the AGENT, whose job is then to tell them.
19
+ //
20
+ // THIS MODULE IS READ-ONLY AND MUST STAY THAT WAY. Patchcord does not edit
21
+ // ~/.claude.json. That file holds a lot more than MCP config, rewriting it
22
+ // races a running Claude Code, and clearing an override is a decision about
23
+ // the human's editor, not about Patchcord. We diagnose; they act.
24
+
25
+ import { existsSync, readFileSync, statSync } from "fs";
26
+ import { createHash } from "crypto";
27
+ import { homedir } from "os";
28
+ import { join, parse as parsePath } from "path";
29
+
30
+ // ~/.claude.json carries whole-session state and can get large. Cap the read
31
+ // so `whoami` can never be slowed to a crawl by a pathological file.
32
+ const MAX_BYTES = 32 * 1024 * 1024;
33
+
34
+ const stripBearer = (v) => String(v || "").replace(/^Bearer\s+/i, "").trim();
35
+
36
+ /** Short, non-reversible tag for a secret — lets a human match two values
37
+ * without either of them ever being printed. */
38
+ export function fingerprint(token) {
39
+ const t = stripBearer(token);
40
+ if (!t) return null;
41
+ return createHash("sha256").update(t).digest("hex").slice(0, 12);
42
+ }
43
+
44
+ /** Every directory from `dir` up to the filesystem root, nearest first. */
45
+ function ancestors(dir) {
46
+ const out = [];
47
+ let cur = dir;
48
+ for (;;) {
49
+ out.push(cur);
50
+ const next = parsePath(cur).dir;
51
+ if (!next || next === cur) return out;
52
+ cur = next;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @returns {null | {
58
+ * code: string, tell_human: string, project_dir: string,
59
+ * local_bearer_fp: string|null, disk_bearer_fp: string|null,
60
+ * claude_config: string,
61
+ * }}
62
+ *
63
+ * Returns null — never throws — when there is nothing to report OR when the
64
+ * check itself cannot run. A diagnostic that can break `whoami` is worse than
65
+ * no diagnostic: whoami is what an agent reaches for when everything else is
66
+ * already broken.
67
+ */
68
+ export function detectClaudeLocalMcpOverride({
69
+ cwd = process.cwd(),
70
+ diskToken = null,
71
+ home = homedir(),
72
+ serverKey = "patchcord",
73
+ } = {}) {
74
+ try {
75
+ const claudeConfig = join(home, ".claude.json");
76
+ if (!existsSync(claudeConfig)) return null;
77
+ if (statSync(claudeConfig).size > MAX_BYTES) return null;
78
+
79
+ const parsed = JSON.parse(readFileSync(claudeConfig, "utf-8"));
80
+ const projects = parsed?.projects;
81
+ if (!projects || typeof projects !== "object") return null;
82
+
83
+ // Claude keys by the ABSOLUTE directory it was launched in, which may be
84
+ // an ancestor of the cwd this command runs in (an agent working in a
85
+ // subdirectory, a worktree helper). Nearest match wins, mirroring how
86
+ // .mcp.json itself is resolved.
87
+ let projectDir = null;
88
+ let entry = null;
89
+ for (const dir of ancestors(cwd)) {
90
+ const candidate = projects?.[dir]?.mcpServers?.[serverKey];
91
+ if (candidate) { projectDir = dir; entry = candidate; break; }
92
+ }
93
+ if (!entry) return null;
94
+
95
+ const localToken = stripBearer(entry?.headers?.Authorization);
96
+ const disk = stripBearer(diskToken);
97
+
98
+ // Nothing on disk to compare against: the local entry is the only config,
99
+ // so it is not SHADOWING anything and this is not the bug.
100
+ if (!disk) return null;
101
+ // Present and identical is harmless today. It is a latent trap — it will
102
+ // silently go stale on the next rotation — but warning on it would fire
103
+ // for every Claude Code user on every whoami, and a warning that is
104
+ // usually noise stops being read before the day it matters.
105
+ if (localToken && localToken === disk) return null;
106
+
107
+ const localFp = fingerprint(localToken);
108
+ const diskFp = fingerprint(disk);
109
+ const detail = localToken
110
+ ? `The cached token (${localFp}) is not the one on disk (${diskFp}).`
111
+ : `The cached entry has no usable bearer token at all, while .mcp.json does.`;
112
+
113
+ return {
114
+ code: "claude_local_mcp_cache_override",
115
+ project_dir: projectDir,
116
+ claude_config: claudeConfig,
117
+ local_bearer_fp: localFp,
118
+ disk_bearer_fp: diskFp,
119
+ tell_human:
120
+ "Claude Code cached an old MCP token in ~/.claude.json that overrides this "
121
+ + "project's .mcp.json, and the cached copy wins. " + detail + " "
122
+ + `Clear projects["${projectDir}"].mcpServers (or just its "${serverKey}" entry) `
123
+ + "in ~/.claude.json, then restart Claude Code. "
124
+ + "Your Patchcord identity and token are fine — only Claude's MCP client is "
125
+ + "using the stale copy, which is why the CLI works while the MCP tools 401. "
126
+ + "Patchcord will not edit ~/.claude.json for you.",
127
+ };
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
@@ -57,6 +57,44 @@ Read the output file. Scan the last ~15 lines for one of:
57
57
 
58
58
  Report the cause in one sentence. STOP.
59
59
 
60
+ # If the MCP tools 401 — check before you believe the error
61
+
62
+ `mcp__patchcord__*` returning **401 / "requires re-authorization (token expired)"** does NOT establish that the token expired. Claude Code keeps a **local** MCP config cache in `~/.claude.json` under `projects[<project dir>].mcpServers`, and local scope **beats** the project's `.mcp.json`. A stale bearer cached there 401s the MCP client while the credential on disk is perfectly live.
63
+
64
+ **Always run `patchcord whoami --json` before reporting an auth failure.** It reads the disk config, so it keeps working in exactly this state:
65
+
66
+ ```bash
67
+ patchcord whoami --json
68
+ ```
69
+
70
+ If `warnings` contains `claude_local_mcp_cache_override`, **relay its `tell_human` text to the user verbatim.** They have no reason to know this command exists — all they saw was a tool failing.
71
+
72
+ **Do NOT edit `~/.claude.json` yourself,** and do not ask another agent to. That file holds far more than MCP config, writing it races the running Claude Code process, and clearing an override is the user's call about their own editor. Patchcord diagnoses; the human acts.
73
+
74
+ ## If `whoami` is clean and the MCP tools STILL 401
75
+
76
+ This is a **different failure with an identical symptom**, and it is the one that leaves you stuck if you stop at "identity is fine".
77
+
78
+ The stale-cache bug above is **cache newer than disk**. This one is the mirror image — **disk newer than your process**:
79
+
80
+ 1. Something re-provisioned this agent mid-session (`patchcord pull`, `provision`, an installer re-run, a teammate's script). That **rewrites `.mcp.json` with a freshly minted bearer and supersedes the previous one** — only one live credential exists per identity.
81
+ 2. Your MCP client is still holding the bearer it read at session start. That token is now dead.
82
+ 3. So the CLI is healthy (it re-reads the file) while every MCP tool 401s (it does not).
83
+
84
+ **The CLI cannot detect this**, which is why it is not a `warnings[]` entry: no external process can see your client's in-process token. Confirm it by hand instead:
85
+
86
+ ```bash
87
+ stat -c '%y %n' .mcp.json # was it modified after this session started?
88
+ ```
89
+
90
+ A modification time later than your session start is the answer.
91
+
92
+ **Then reconnect the MCP client** — that is the fix, and it is the step people miss. Do **not** conclude that the token is broken, and do not keep retrying `inbox()`: a superseded token will 401 forever, and repeating the call reports the same error indefinitely. If reconnecting is not something you can do yourself, **tell the human that the MCP client needs reconnecting and why**, naming the rewrite time.
93
+
94
+ Reported by `lead@mux-v2`, who followed the procedure above, correctly concluded "not that bug", and then had nowhere to go for several hours.
95
+
96
+ If `whoami` is clean, `.mcp.json` was not touched this session, and the tools still fail, the problem is neither of these — say so plainly rather than guessing at the token.
97
+
60
98
  **Forbidden on failure:** no `pgrep`/`ps`/`kill`/`pkill`/`killall`, no pidfile writes, no respawning. The script manages pidfile cleanup itself; respawning will not fix a config problem.
61
99
 
62
100
  No matching error pattern = the listener exited cleanly (session ended, user killed it, or EPIPE detected). Nothing to do.