patchcord 0.6.28 → 0.6.30
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/.claude-plugin/plugin.json +1 -1
- package/bin/patchcord.mjs +272 -46
- package/package.json +1 -1
- package/scripts/check-inbox.sh +41 -15
- package/scripts/codex-stop-hook.sh +43 -9
- package/scripts/cursor-stop-hook.sh +167 -0
- package/scripts/lib/claude-local-mcp.mjs +131 -0
- package/scripts/lib/inbox-guard.sh +88 -0
- package/skills/subscribe/SKILL.md +38 -0
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
|
|
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,6 +196,16 @@ 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
|
+
patchcord team deployment request --namespace <ns> [--revision <uid>] [--json]
|
|
200
|
+
enqueue an apply of a saved revision
|
|
201
|
+
(exit 3 = one is already in flight)
|
|
202
|
+
patchcord team deployment pending [--json] jobs waiting for this host to claim
|
|
203
|
+
patchcord team deployment claim --job <uid> [--installation <name>] [--json]
|
|
204
|
+
take ownership (exit 3 = lost the race)
|
|
205
|
+
patchcord team deployment complete --job <uid> --status <healthy|degraded|cancelled>
|
|
206
|
+
[--result <json> | --result-file <path>] [--json]
|
|
207
|
+
patchcord team deployment get --namespace <ns> (--job <uid> | --active) [--json]
|
|
208
|
+
one job, or the namespace's active job
|
|
192
209
|
patchcord team status reconcile folder ↔ identity ↔ tmux ↔ token
|
|
193
210
|
patchcord team launch launch each worker in mux
|
|
194
211
|
|
|
@@ -711,6 +728,14 @@ if (cmd === "whoami") {
|
|
|
711
728
|
console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
|
|
712
729
|
process.exit(1);
|
|
713
730
|
}
|
|
731
|
+
// Claude Code's local MCP cache can shadow .mcp.json with a stale bearer,
|
|
732
|
+
// which is exactly the state in which an agent reaches for whoami: the CLI
|
|
733
|
+
// works, the MCP tools 401, and the error blames the token. Read-only; see
|
|
734
|
+
// scripts/lib/claude-local-mcp.mjs.
|
|
735
|
+
const warnings = [];
|
|
736
|
+
const localOverride = detectClaudeLocalMcpOverride({ cwd: process.cwd(), diskToken: token });
|
|
737
|
+
if (localOverride) warnings.push(localOverride);
|
|
738
|
+
|
|
714
739
|
if (wantJson) {
|
|
715
740
|
const out = {
|
|
716
741
|
agent: json.agent,
|
|
@@ -720,6 +745,7 @@ if (cmd === "whoami") {
|
|
|
720
745
|
tool: found.tool || null,
|
|
721
746
|
config_file: found.configFile || null,
|
|
722
747
|
scope: found.scope || null,
|
|
748
|
+
warnings,
|
|
723
749
|
};
|
|
724
750
|
process.stdout.write(JSON.stringify(out) + "\n");
|
|
725
751
|
process.exit(0);
|
|
@@ -737,6 +763,14 @@ if (cmd === "whoami") {
|
|
|
737
763
|
}
|
|
738
764
|
console.log();
|
|
739
765
|
console.log(`tip: \`patchcord agents\` returns whoami for all peers.`);
|
|
766
|
+
for (const w of warnings) {
|
|
767
|
+
// stderr, matching the global-scope warning above. The line is addressed
|
|
768
|
+
// to the AGENT and says to relay it, because the human has no reason to
|
|
769
|
+
// know this command exists and will only ever see the 401.
|
|
770
|
+
console.error();
|
|
771
|
+
console.error(`\x1b[33m⚠ ${w.code}`);
|
|
772
|
+
console.error(` TELL THE HUMAN: ${w.tell_human}\x1b[0m`);
|
|
773
|
+
}
|
|
740
774
|
process.exit(0);
|
|
741
775
|
}
|
|
742
776
|
|
|
@@ -1236,42 +1270,34 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1236
1270
|
return { ...r, m };
|
|
1237
1271
|
};
|
|
1238
1272
|
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
// Cursor project config
|
|
1268
|
-
if (primaryTool !== "cursor") {
|
|
1269
|
-
touchJson(join(dir, ".cursor", "mcp.json"), (o) => {
|
|
1270
|
-
if (!o.mcpServers?.patchcord) return;
|
|
1271
|
-
o.mcpServers.patchcord = { url: `${baseUrl}/mcp/bearer`, headers: hdr };
|
|
1272
|
-
});
|
|
1273
|
-
}
|
|
1274
|
-
};
|
|
1273
|
+
// ── ONE HARNESS PER COMMAND — no sibling writes, ever ──
|
|
1274
|
+
//
|
|
1275
|
+
// This file used to run `syncSiblingPatchcordTokens` after every write:
|
|
1276
|
+
// remint into the harness named by --tool, then stamp that same bearer into
|
|
1277
|
+
// every OTHER patchcord config in the directory. The stated reason was to
|
|
1278
|
+
// avoid leaving a superseded pct- beside a live one, and that reason only
|
|
1279
|
+
// holds if the sibling files are copies of ONE identity.
|
|
1280
|
+
//
|
|
1281
|
+
// They routinely are not. The product rule is: at most one patchcord config
|
|
1282
|
+
// PER HARNESS in a folder, and a folder MAY hold several harnesses at once —
|
|
1283
|
+
// and those may be DIFFERENT agents:
|
|
1284
|
+
//
|
|
1285
|
+
// .mcp.json -> lead@mux-v2 (claude_code)
|
|
1286
|
+
// .cursor/mcp.json -> mux-cursor@mux (cursor)
|
|
1287
|
+
//
|
|
1288
|
+
// There is no "one directory, one shared token" rule and there never was. So
|
|
1289
|
+
// `patchcord pull mux-cursor --tool cursor` was silently overwriting another
|
|
1290
|
+
// agent's live credential — an identity never named on the command line —
|
|
1291
|
+
// and breaking Claude's MCP when only Cursor had been asked for. It burned a
|
|
1292
|
+
// production team.
|
|
1293
|
+
//
|
|
1294
|
+
// The rule now, with no exception and no opt-in flag: WRITE ONLY THE CONFIG
|
|
1295
|
+
// FOR THE HARNESS NAMED BY --tool. A same-identity sync would still have to
|
|
1296
|
+
// prove the sibling is the same agent, and the case it most wants to fix —
|
|
1297
|
+
// a sibling whose token is already dead — is exactly the case that cannot be
|
|
1298
|
+
// attributed: "my own stale copy" and "another agent's stale copy" look
|
|
1299
|
+
// identical from here. Re-pull that harness by name instead; it is
|
|
1300
|
+
// unambiguous, and it names the identity being changed.
|
|
1275
1301
|
|
|
1276
1302
|
const writeWorkerConfig = (tool, dir, baseUrl, token, hostname) => {
|
|
1277
1303
|
mkdirSync(dir, { recursive: true });
|
|
@@ -1334,7 +1360,8 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1334
1360
|
headers: hdr,
|
|
1335
1361
|
};
|
|
1336
1362
|
});
|
|
1337
|
-
|
|
1363
|
+
// No sibling writes. `--tool cursor` touches cursor's config and nothing
|
|
1364
|
+
// else; .mcp.json in this directory may be a different agent entirely.
|
|
1338
1365
|
return written;
|
|
1339
1366
|
}
|
|
1340
1367
|
if (tool === "antigravity" || tool === "agy") {
|
|
@@ -1378,10 +1405,34 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1378
1405
|
o.mcpServers = o.mcpServers || {};
|
|
1379
1406
|
o.mcpServers.patchcord = { type: "http", url: `${baseUrl}/mcp`, headers: hdr };
|
|
1380
1407
|
});
|
|
1381
|
-
|
|
1408
|
+
// No sibling writes. .cursor/mcp.json in this directory may hold a
|
|
1409
|
+
// different agent's live credential.
|
|
1382
1410
|
return primary;
|
|
1383
1411
|
};
|
|
1384
1412
|
|
|
1413
|
+
/** --tool is REQUIRED on anything that writes a patchcord MCP config.
|
|
1414
|
+
*
|
|
1415
|
+
* It used to default to claude_code, and that default was a guess about
|
|
1416
|
+
* WHICH AGENT to overwrite. In a folder holding one config per harness —
|
|
1417
|
+
* each possibly a different identity — silently choosing claude_code means
|
|
1418
|
+
* `patchcord pull <agent>` rewrites `.mcp.json` and whatever identity lived
|
|
1419
|
+
* there, without that identity appearing anywhere on the command line.
|
|
1420
|
+
* Refusing costs one flag; guessing costs someone's live credential. */
|
|
1421
|
+
const requireToolFlag = (usage) => {
|
|
1422
|
+
const raw = (flagVal("tool", "") || "").trim();
|
|
1423
|
+
if (raw) return raw;
|
|
1424
|
+
console.error(`patchcord: --tool is required.`);
|
|
1425
|
+
console.error(` It names which harness config to write — and therefore which agent.`);
|
|
1426
|
+
console.error(` One folder may hold one patchcord config per harness, and they can be`);
|
|
1427
|
+
console.error(` DIFFERENT identities:`);
|
|
1428
|
+
console.error(` .mcp.json → claude_code`);
|
|
1429
|
+
console.error(` .cursor/mcp.json → cursor`);
|
|
1430
|
+
console.error(` Defaulting would overwrite an agent you did not name, so there is no default.`);
|
|
1431
|
+
console.error(` One of: claude_code, codex, cursor, kimi, opencode, antigravity, grok, hermes`);
|
|
1432
|
+
console.error(` ${usage}`);
|
|
1433
|
+
process.exit(1);
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1385
1436
|
if (cmd === "login") {
|
|
1386
1437
|
// Explicit CLI login. Other commands log in on demand via requireAuth(),
|
|
1387
1438
|
// so this is only needed to authenticate ahead of time or switch accounts.
|
|
@@ -1401,7 +1452,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1401
1452
|
console.log(`✓ revoked ${ns}:${ra} (${json.count} token(s))`);
|
|
1402
1453
|
process.exit(0);
|
|
1403
1454
|
}
|
|
1404
|
-
const tool =
|
|
1455
|
+
const tool = requireToolFlag("patchcord provision <agent> --tool X --role Y --namespace ns [--dir sub/] [--project name] [--lead]");
|
|
1405
1456
|
const role = flagVal("role", "");
|
|
1406
1457
|
const ns = flagVal("namespace");
|
|
1407
1458
|
const subdir = flagVal("dir", arg);
|
|
@@ -1420,7 +1471,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1420
1471
|
if (lead) provisionBody.is_lead = true;
|
|
1421
1472
|
const { status, json, m } = await accountCall("POST", "/api/provision", provisionBody);
|
|
1422
1473
|
if (status !== "200" || !json?.token) { console.error(`provision failed (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
|
|
1423
|
-
const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp
|
|
1474
|
+
const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp(\/bearer)?$/, "");
|
|
1424
1475
|
const dir = join(process.cwd(), subdir);
|
|
1425
1476
|
const hostname = run("hostname -s") || run("hostname") || "unknown";
|
|
1426
1477
|
writeWorkerConfig(tool, dir, base, json.token, hostname);
|
|
@@ -1490,7 +1541,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1490
1541
|
// symmetry only; server is source of truth.
|
|
1491
1542
|
const arg = process.argv[3];
|
|
1492
1543
|
if (!arg || arg.startsWith("-")) { console.error("Usage: patchcord pull <agent> --namespace ns --tool X [--dir sub/] [--lead]"); process.exit(1); }
|
|
1493
|
-
const tool =
|
|
1544
|
+
const tool = requireToolFlag("patchcord pull <agent> --tool X --namespace ns [--dir sub/] [--lead]");
|
|
1494
1545
|
const ns = flagVal("namespace");
|
|
1495
1546
|
const subdir = flagVal("dir", arg);
|
|
1496
1547
|
const lead = process.argv.includes("--lead"); // typed symmetry; server ignores escalate-on-pull
|
|
@@ -1500,7 +1551,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1500
1551
|
const { status, json, m } = await accountCall("POST", "/api/provision", pullBody);
|
|
1501
1552
|
if (status === "404") { console.error(`no agent ${M.green}${ns}:${arg}${M.rst} to pull — create it with: patchcord provision ${arg} --tool ${tool} --namespace ${ns}`); process.exit(1); }
|
|
1502
1553
|
if (status !== "200" || !json?.token) { console.error(`pull failed (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
|
|
1503
|
-
const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp
|
|
1554
|
+
const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp(\/bearer)?$/, "");
|
|
1504
1555
|
const dir = join(process.cwd(), subdir);
|
|
1505
1556
|
const hostname = run("hostname -s") || run("hostname") || "unknown";
|
|
1506
1557
|
writeWorkerConfig(tool, dir, base, json.token, hostname);
|
|
@@ -1527,7 +1578,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1527
1578
|
// (`teamlead` is kept as a back-compat alias for the command name.)
|
|
1528
1579
|
const root = process.cwd();
|
|
1529
1580
|
const ns = (flagVal("namespace", basename(root)) || "team").replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
1530
|
-
const tool =
|
|
1581
|
+
const tool = requireToolFlag("patchcord orchestrator --tool X [--namespace ns] [--project name]");
|
|
1531
1582
|
const project = flagVal("project", "");
|
|
1532
1583
|
const hostname = run("hostname -s") || run("hostname") || "unknown";
|
|
1533
1584
|
// is_lead: true always — the orchestrator IS the one identity per namespace
|
|
@@ -1537,7 +1588,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1537
1588
|
// until both leads explicitly consent).
|
|
1538
1589
|
const { status, json, m } = await accountCall("POST", "/api/provision", { namespace_id: ns, agent_id: "orchestrator", tool, role: "orchestrator", label: "orchestrator:self", is_lead: true });
|
|
1539
1590
|
if (status !== "200" || !json?.token) { console.error(`could not set up orchestrator (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
|
|
1540
|
-
const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp
|
|
1591
|
+
const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp(\/bearer)?$/, "");
|
|
1541
1592
|
writeWorkerConfig(tool, root, base, json.token, hostname);
|
|
1542
1593
|
if (project) {
|
|
1543
1594
|
const { status: pStatus, json: pJson } = await accountCall("POST", "/api/namespace/set-project", { namespace_id: ns, project });
|
|
@@ -1674,6 +1725,138 @@ you design the team, provision its agents, launch them, and manage them.
|
|
|
1674
1725
|
console.error("Usage: patchcord team blueprint <current|push> --namespace <ns> [--json]");
|
|
1675
1726
|
process.exit(1);
|
|
1676
1727
|
}
|
|
1728
|
+
if (sub === "deployment" || sub === "deployments") {
|
|
1729
|
+
// Deploy = apply a saved revision to live panes. Deliberately NOT part of
|
|
1730
|
+
// a save: `blueprint push` writes an immutable revision, this asks the
|
|
1731
|
+
// owning Mux installation to enact one. Mux shells out to these; its
|
|
1732
|
+
// poller treats an unknown subcommand as an empty pending list, so an
|
|
1733
|
+
// older CLI degrades to "nothing to do" rather than crashing a host.
|
|
1734
|
+
const dsub = process.argv[4];
|
|
1735
|
+
const wantJson = process.argv.includes("--json");
|
|
1736
|
+
const fail = (label, status, json, body) => {
|
|
1737
|
+
const detail = (json && (json.error || json.code || JSON.stringify(json.detail))) || body || "";
|
|
1738
|
+
console.error(`deployment ${label} failed (HTTP ${status}): ${detail}`);
|
|
1739
|
+
process.exit(1);
|
|
1740
|
+
};
|
|
1741
|
+
const emit = (json) => {
|
|
1742
|
+
if (wantJson) { process.stdout.write(JSON.stringify(json) + "\n"); process.exit(0); }
|
|
1743
|
+
};
|
|
1744
|
+
const printJob = (j) => {
|
|
1745
|
+
console.log(`job_uid: ${j.job_uid}`);
|
|
1746
|
+
console.log(`namespace: ${j.namespace}`);
|
|
1747
|
+
console.log(`status: ${j.status}`);
|
|
1748
|
+
console.log(`revision_uid: ${j.revision_uid}`);
|
|
1749
|
+
console.log(`sha256: ${j.sha256}`);
|
|
1750
|
+
if (j.claimed_by) console.log(`claimed_by: ${j.claimed_by}`);
|
|
1751
|
+
if (j.result && Object.keys(j.result).length) console.log(`result: ${JSON.stringify(j.result)}`);
|
|
1752
|
+
};
|
|
1753
|
+
|
|
1754
|
+
if (dsub === "request") {
|
|
1755
|
+
const ns = flagVal("namespace");
|
|
1756
|
+
const rev = flagVal("revision");
|
|
1757
|
+
if (!ns) { console.error("Usage: patchcord team deployment request --namespace <ns> [--revision <uid>] [--json]"); process.exit(1); }
|
|
1758
|
+
const payload = rev ? { revision_uid: rev } : {};
|
|
1759
|
+
const { status, json, body } = await accountCall("POST", `/api/team/${encodeURIComponent(ns)}/deployments`, payload);
|
|
1760
|
+
// 409 is not an error to the caller: a deploy is already in flight and
|
|
1761
|
+
// the response carries it, so emit that job rather than failing. The
|
|
1762
|
+
// exit code still distinguishes the two.
|
|
1763
|
+
if (status === "409" && json && json.job) {
|
|
1764
|
+
if (wantJson) { process.stdout.write(JSON.stringify({ ...json.job, already_active: true }) + "\n"); process.exit(3); }
|
|
1765
|
+
console.log(`already active (${json.code})`);
|
|
1766
|
+
printJob(json.job);
|
|
1767
|
+
process.exit(3);
|
|
1768
|
+
}
|
|
1769
|
+
if (status !== "200" && status !== "201") fail("request", status, json, body);
|
|
1770
|
+
emit(json);
|
|
1771
|
+
printJob(json);
|
|
1772
|
+
process.exit(0);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
if (dsub === "pending") {
|
|
1776
|
+
const { status, json, body } = await accountCall("GET", "/api/deployments/pending");
|
|
1777
|
+
if (status !== "200") fail("pending", status, json, body);
|
|
1778
|
+
emit(json);
|
|
1779
|
+
const jobs = (json && json.jobs) || [];
|
|
1780
|
+
if (!jobs.length) { console.log("(no pending deployments)"); process.exit(0); }
|
|
1781
|
+
for (const j of jobs) console.log(` ${j.job_uid} ${j.namespace} ${j.sha256.slice(0, 12)} ${j.status}`);
|
|
1782
|
+
process.exit(0);
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
if (dsub === "claim") {
|
|
1786
|
+
const job = flagVal("job");
|
|
1787
|
+
const installation = flagVal("installation");
|
|
1788
|
+
if (!job) { console.error("Usage: patchcord team deployment claim --job <uid> [--installation <name>] [--json]"); process.exit(1); }
|
|
1789
|
+
const { status, json, body } = await accountCall(
|
|
1790
|
+
"POST", `/api/deployments/${encodeURIComponent(job)}/claim`,
|
|
1791
|
+
installation ? { installation } : {},
|
|
1792
|
+
);
|
|
1793
|
+
// Losing a claim race is normal in a multi-host account, not a failure:
|
|
1794
|
+
// exit 3 so a poller can skip to the next job without parsing stderr.
|
|
1795
|
+
if (status === "409") {
|
|
1796
|
+
if (wantJson) { process.stdout.write(JSON.stringify(json) + "\n"); process.exit(3); }
|
|
1797
|
+
console.log(`not claimable (${(json && json.code) || "conflict"})`);
|
|
1798
|
+
process.exit(3);
|
|
1799
|
+
}
|
|
1800
|
+
if (status !== "200") fail("claim", status, json, body);
|
|
1801
|
+
emit(json);
|
|
1802
|
+
printJob(json);
|
|
1803
|
+
process.exit(0);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
if (dsub === "complete") {
|
|
1807
|
+
const job = flagVal("job");
|
|
1808
|
+
const st = flagVal("status");
|
|
1809
|
+
const resultRaw = flagVal("result");
|
|
1810
|
+
const resultFile = flagVal("result-file");
|
|
1811
|
+
if (!job || !st) {
|
|
1812
|
+
console.error("Usage: patchcord team deployment complete --job <uid> --status <healthy|degraded|cancelled> [--result <json> | --result-file <path>] [--json]");
|
|
1813
|
+
process.exit(1);
|
|
1814
|
+
}
|
|
1815
|
+
let result = {};
|
|
1816
|
+
try {
|
|
1817
|
+
if (resultFile) result = JSON.parse(readFileSync(resultFile, "utf-8"));
|
|
1818
|
+
else if (resultRaw) result = JSON.parse(resultRaw);
|
|
1819
|
+
} catch (e) { console.error(`cannot parse result JSON: ${e.message}`); process.exit(1); }
|
|
1820
|
+
const { status, json, body } = await accountCall(
|
|
1821
|
+
"POST", `/api/deployments/${encodeURIComponent(job)}/complete`,
|
|
1822
|
+
{ status: st, result },
|
|
1823
|
+
);
|
|
1824
|
+
if (status === "409") {
|
|
1825
|
+
// The lease lapsed and the job was requeued or failed while this host
|
|
1826
|
+
// was applying. Reporting into it would overwrite a fresher attempt.
|
|
1827
|
+
if (wantJson) { process.stdout.write(JSON.stringify(json) + "\n"); process.exit(3); }
|
|
1828
|
+
console.log(`not running (${(json && json.code) || "conflict"}) — lease likely lapsed`);
|
|
1829
|
+
process.exit(3);
|
|
1830
|
+
}
|
|
1831
|
+
if (status !== "200") fail("complete", status, json, body);
|
|
1832
|
+
emit(json);
|
|
1833
|
+
printJob(json);
|
|
1834
|
+
process.exit(0);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
if (dsub === "get") {
|
|
1838
|
+
const ns = flagVal("namespace");
|
|
1839
|
+
const job = flagVal("job");
|
|
1840
|
+
const active = process.argv.includes("--active");
|
|
1841
|
+
if (!ns || (!job && !active)) {
|
|
1842
|
+
console.error("Usage: patchcord team deployment get --namespace <ns> (--job <uid> | --active) [--json]");
|
|
1843
|
+
process.exit(1);
|
|
1844
|
+
}
|
|
1845
|
+
const path = active
|
|
1846
|
+
? `/api/team/${encodeURIComponent(ns)}/deployments?active=1`
|
|
1847
|
+
: `/api/team/${encodeURIComponent(ns)}/deployments/${encodeURIComponent(job)}`;
|
|
1848
|
+
const { status, json, body } = await accountCall("GET", path);
|
|
1849
|
+
if (status !== "200") fail("get", status, json, body);
|
|
1850
|
+
emit(json);
|
|
1851
|
+
const j = active ? (json && json.job) : json;
|
|
1852
|
+
if (!j) { console.log("(no active deployment)"); process.exit(0); }
|
|
1853
|
+
printJob(j);
|
|
1854
|
+
process.exit(0);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
console.error("Usage: patchcord team deployment <request|pending|claim|complete|get> [--json]");
|
|
1858
|
+
process.exit(1);
|
|
1859
|
+
}
|
|
1677
1860
|
if (sub === "status") {
|
|
1678
1861
|
// Reconciler: folder ↔ patchcord identity ↔ tmux session ↔ token-active.
|
|
1679
1862
|
// Truth = .patchcord/team.json. For each agent we resolve the token from
|
|
@@ -1810,7 +1993,7 @@ you design the team, provision its agents, launch them, and manage them.
|
|
|
1810
1993
|
}
|
|
1811
1994
|
process.exit(0);
|
|
1812
1995
|
}
|
|
1813
|
-
console.error("Usage: patchcord team <list|blueprint|launch|status>");
|
|
1996
|
+
console.error("Usage: patchcord team <list|blueprint|deployment|launch|status>");
|
|
1814
1997
|
process.exit(1);
|
|
1815
1998
|
}
|
|
1816
1999
|
|
|
@@ -2192,6 +2375,40 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
|
|
|
2192
2375
|
if (_approveCursorProjectMcp(process.cwd())) {
|
|
2193
2376
|
globalChanges.push("Cursor MCP approved for this project");
|
|
2194
2377
|
}
|
|
2378
|
+
|
|
2379
|
+
// Stop hook — a SECOND wake path, independent of the subscribe listener.
|
|
2380
|
+
// The listener's notify_on_output wakes a seat both busy and idle
|
|
2381
|
+
// (verified live), so an armed listener needs no help. This covers the
|
|
2382
|
+
// case where no listener is running at all: never armed, armed without
|
|
2383
|
+
// notify_on_output, or the process died. The `stop` hook fires on the
|
|
2384
|
+
// harness's own event and needs no long-lived process, so it survives
|
|
2385
|
+
// exactly the failure that silences subscribe.
|
|
2386
|
+
// NOTE: cursor-agent loads ~/.cursor/hooks.json at STARTUP only — a
|
|
2387
|
+
// session already running when this is written will not fire it.
|
|
2388
|
+
const cursorHookSrc = join(pluginRoot, "scripts", "cursor-stop-hook.sh");
|
|
2389
|
+
if (existsSync(cursorHookSrc)) {
|
|
2390
|
+
const cursorHookDest = join(HOME, ".cursor", "patchcord-stop-hook.sh");
|
|
2391
|
+
const hookExisted = existsSync(cursorHookDest);
|
|
2392
|
+
mkdirSync(join(HOME, ".cursor"), { recursive: true });
|
|
2393
|
+
cpSync(cursorHookSrc, cursorHookDest);
|
|
2394
|
+
chmodSync(cursorHookDest, 0o755);
|
|
2395
|
+
|
|
2396
|
+
const cursorHooksPath = join(HOME, ".cursor", "hooks.json");
|
|
2397
|
+
const hooksOk = updateJsonConfig(cursorHooksPath, (obj) => {
|
|
2398
|
+
if (obj.version === undefined) obj.version = 1;
|
|
2399
|
+
if (!obj.hooks || typeof obj.hooks !== "object" || Array.isArray(obj.hooks)) obj.hooks = {};
|
|
2400
|
+
const existing = Array.isArray(obj.hooks.stop) ? obj.hooks.stop : [];
|
|
2401
|
+
// Drop any prior patchcord entry (path may have moved) and re-add.
|
|
2402
|
+
const kept = existing.filter((e) => !JSON.stringify(e).includes("patchcord-stop-hook"));
|
|
2403
|
+
kept.push({ command: cursorHookDest });
|
|
2404
|
+
obj.hooks.stop = kept;
|
|
2405
|
+
});
|
|
2406
|
+
if (!hooksOk) {
|
|
2407
|
+
globalChanges.push("✗ Cursor stop hook error");
|
|
2408
|
+
} else {
|
|
2409
|
+
globalChanges.push(`Cursor stop hook ${hookExisted ? "updated" : "installed"}`);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2195
2412
|
}
|
|
2196
2413
|
}
|
|
2197
2414
|
|
|
@@ -2520,6 +2737,15 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
|
|
|
2520
2737
|
copyFileSync(hookScriptSrc, hookScriptDest);
|
|
2521
2738
|
chmodSync(hookScriptDest, 0o755);
|
|
2522
2739
|
|
|
2740
|
+
// The hook sources this for the identity hint + loop brake. It lives in
|
|
2741
|
+
// scripts/lib/ in the repo, but the hook runs from ~/.codex, so ship a
|
|
2742
|
+
// copy beside it — otherwise the hook silently falls back to the older,
|
|
2743
|
+
// loop-prone wording.
|
|
2744
|
+
const guardSrc = join(pluginRoot, "scripts", "lib", "inbox-guard.sh");
|
|
2745
|
+
if (existsSync(guardSrc)) {
|
|
2746
|
+
copyFileSync(guardSrc, join(HOME, ".codex", "patchcord-inbox-guard.sh"));
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2523
2749
|
// Enable the `hooks` feature flag exactly once. Older installs / Codex
|
|
2524
2750
|
// versions may leave `codex_hooks = true`, `hooks=true` (no spaces), a
|
|
2525
2751
|
// `hooks = false`, or even a duplicate `hooks = true` — any of which makes
|
package/package.json
CHANGED
package/scripts/check-inbox.sh
CHANGED
|
@@ -4,6 +4,18 @@ set -euo pipefail
|
|
|
4
4
|
# jq is required — skip silently if not installed (fresh macOS)
|
|
5
5
|
command -v jq >/dev/null 2>&1 || exit 0
|
|
6
6
|
|
|
7
|
+
HOOK_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
8
|
+
if [ -f "$HOOK_DIR/lib/inbox-guard.sh" ]; then
|
|
9
|
+
# shellcheck source=lib/inbox-guard.sh
|
|
10
|
+
. "$HOOK_DIR/lib/inbox-guard.sh"
|
|
11
|
+
else
|
|
12
|
+
# Degrade to the pre-guard behaviour rather than dying: a partial install
|
|
13
|
+
# must still nudge, just without the identity hint or the loop breaker.
|
|
14
|
+
pc_inbox_reason() { printf '%s patchcord message(s) waiting. Call inbox() and reply to all immediately.' "$1"; }
|
|
15
|
+
pc_streak_ok() { return 0; }
|
|
16
|
+
pc_state_path() { printf ''; }
|
|
17
|
+
fi
|
|
18
|
+
|
|
7
19
|
INPUT=$(cat)
|
|
8
20
|
|
|
9
21
|
# Validate input is parseable JSON before proceeding — Claude Code may include
|
|
@@ -118,22 +130,36 @@ fi
|
|
|
118
130
|
|
|
119
131
|
# ── Inbox notification (deduplicated across Stop + Notification hooks) ──
|
|
120
132
|
COUNT=$(echo "$RESPONSE" | jq -r '.count // .pending_count // 0' 2>/dev/null || echo "0")
|
|
133
|
+
case "$COUNT" in
|
|
134
|
+
''|*[!0-9]*) COUNT=0 ;;
|
|
135
|
+
esac
|
|
121
136
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
STREAK_STATE=$(pc_state_path "patchcord_inbox_streak" "$NAMESPACE" "$AGENT_ID" 2>/dev/null || echo "")
|
|
138
|
+
|
|
139
|
+
if [ "$COUNT" -eq 0 ]; then
|
|
140
|
+
[ -n "$STREAK_STATE" ] && rm -f "$STREAK_STATE"
|
|
141
|
+
exit 0
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
NOTIFY_LOCK="/tmp/patchcord_notify_lock"
|
|
145
|
+
LOCK_AGE=5
|
|
146
|
+
if [ -f "$NOTIFY_LOCK" ]; then
|
|
147
|
+
LOCK_MTIME=$(stat -c %Y "$NOTIFY_LOCK" 2>/dev/null || stat -f %m "$NOTIFY_LOCK" 2>/dev/null || echo "0")
|
|
148
|
+
NOW=$(date +%s)
|
|
149
|
+
if [ $(( NOW - LOCK_MTIME )) -lt $LOCK_AGE ]; then
|
|
150
|
+
exit 0 # Already notified within 5s
|
|
131
151
|
fi
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
152
|
+
fi
|
|
153
|
+
|
|
154
|
+
# Give up after MAX identical blocks. Keyed on identity+count, so a genuinely
|
|
155
|
+
# new message resets it and always gets through — but a hook that keeps
|
|
156
|
+
# counting messages the session cannot see (stale on-disk token, see
|
|
157
|
+
# lib/inbox-guard.sh) stops burning turns instead of looping forever.
|
|
158
|
+
if [ -n "$STREAK_STATE" ] \
|
|
159
|
+
&& ! pc_streak_ok "$STREAK_STATE" "${NAMESPACE}/${AGENT_ID}/${COUNT}" 3; then
|
|
138
160
|
exit 0
|
|
139
161
|
fi
|
|
162
|
+
|
|
163
|
+
touch "$NOTIFY_LOCK"
|
|
164
|
+
REASON=$(pc_inbox_reason "$COUNT" "$NAMESPACE" "$AGENT_ID" "inbox()" "restart this Claude Code session")
|
|
165
|
+
jq -n --arg reason "$REASON" '{"decision": "block", "reason": $reason}'
|
|
@@ -6,6 +6,22 @@ set -euo pipefail
|
|
|
6
6
|
|
|
7
7
|
command -v jq >/dev/null 2>&1 || exit 0
|
|
8
8
|
|
|
9
|
+
# This script is COPIED to ~/.codex/patchcord-stop-hook.sh by the installer, so
|
|
10
|
+
# the shared guard is looked for both in the repo layout (scripts/lib/) and
|
|
11
|
+
# beside the installed copy, where `npx patchcord` drops it.
|
|
12
|
+
HOOK_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
13
|
+
if [ -f "$HOOK_DIR/lib/inbox-guard.sh" ]; then
|
|
14
|
+
# shellcheck source=lib/inbox-guard.sh
|
|
15
|
+
. "$HOOK_DIR/lib/inbox-guard.sh"
|
|
16
|
+
elif [ -f "$HOOK_DIR/patchcord-inbox-guard.sh" ]; then
|
|
17
|
+
# shellcheck source=lib/inbox-guard.sh
|
|
18
|
+
. "$HOOK_DIR/patchcord-inbox-guard.sh"
|
|
19
|
+
else
|
|
20
|
+
pc_inbox_reason() { printf '%s patchcord message(s) waiting. Call inbox() and reply to all.' "$1"; }
|
|
21
|
+
pc_streak_ok() { return 0; }
|
|
22
|
+
pc_state_path() { printf ''; }
|
|
23
|
+
fi
|
|
24
|
+
|
|
9
25
|
INPUT=$(cat)
|
|
10
26
|
|
|
11
27
|
PROJECT_CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
|
|
@@ -61,14 +77,32 @@ RESPONSE=$(cat /tmp/patchcord_codex_inbox.json 2>/dev/null || echo '{"count":0}'
|
|
|
61
77
|
rm -f /tmp/patchcord_codex_inbox.json
|
|
62
78
|
|
|
63
79
|
COUNT=$(echo "$RESPONSE" | jq -r '.count // .pending_count // 0' 2>/dev/null || echo "0")
|
|
80
|
+
case "$COUNT" in
|
|
81
|
+
''|*[!0-9]*) COUNT=0 ;;
|
|
82
|
+
esac
|
|
83
|
+
|
|
84
|
+
NAMESPACE=$(echo "$RESPONSE" | jq -r '.namespace_id // empty' 2>/dev/null || true)
|
|
85
|
+
AGENT_ID=$(echo "$RESPONSE" | jq -r '.agent_id // empty' 2>/dev/null || true)
|
|
86
|
+
STREAK_STATE=$(pc_state_path "patchcord_codex_streak" "$NAMESPACE" "$AGENT_ID" 2>/dev/null || echo "")
|
|
87
|
+
|
|
88
|
+
if [ "$COUNT" -eq 0 ]; then
|
|
89
|
+
[ -n "$STREAK_STATE" ] && rm -f "$STREAK_STATE"
|
|
90
|
+
exit 0
|
|
91
|
+
fi
|
|
64
92
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
[ $(( NOW - LOCK_MTIME )) -lt 5 ] && exit 0
|
|
71
|
-
fi
|
|
72
|
-
touch "$NOTIFY_LOCK"
|
|
73
|
-
jq -n --arg count "$COUNT" '{"decision":"block","reason":($count + " patchcord message(s) waiting. Call inbox() and reply to all.")}'
|
|
93
|
+
NOTIFY_LOCK="/tmp/patchcord_codex_notify_lock"
|
|
94
|
+
if [ -f "$NOTIFY_LOCK" ]; then
|
|
95
|
+
LOCK_MTIME=$(stat -c %Y "$NOTIFY_LOCK" 2>/dev/null || stat -f %m "$NOTIFY_LOCK" 2>/dev/null || echo "0")
|
|
96
|
+
NOW=$(date +%s)
|
|
97
|
+
[ $(( NOW - LOCK_MTIME )) -lt 5 ] && exit 0
|
|
74
98
|
fi
|
|
99
|
+
|
|
100
|
+
# Same-count/same-identity blocks give up after 3 — see lib/inbox-guard.sh.
|
|
101
|
+
if [ -n "$STREAK_STATE" ] \
|
|
102
|
+
&& ! pc_streak_ok "$STREAK_STATE" "${NAMESPACE}/${AGENT_ID}/${COUNT}" 3; then
|
|
103
|
+
exit 0
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
touch "$NOTIFY_LOCK"
|
|
107
|
+
REASON=$(pc_inbox_reason "$COUNT" "$NAMESPACE" "$AGENT_ID" "inbox()" "restart this Codex session")
|
|
108
|
+
jq -n --arg reason "$REASON" '{"decision":"block","reason":$reason}'
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Cursor `stop` hook — checks the patchcord inbox at the end of every turn.
|
|
5
|
+
# Installed automatically by `npx patchcord` when cursor-agent is detected.
|
|
6
|
+
#
|
|
7
|
+
# Why this exists: cursor-agent has no push-from-background-shell mechanism.
|
|
8
|
+
# Its Shell tool exposes only the pull-shaped Await contract
|
|
9
|
+
# (task_id / block_until_ms / regex), so a background `patchcord subscribe`
|
|
10
|
+
# listener can print "PATCHCORD:" lines that nothing ever reads. Hooks are the
|
|
11
|
+
# one inbound path Cursor actually offers, and `stop` is the one that fires
|
|
12
|
+
# when the agent goes idle — exactly when an unread message would otherwise sit
|
|
13
|
+
# until a human pokes the session.
|
|
14
|
+
#
|
|
15
|
+
# Contract — captured from a live cursor-agent 2026.07.23 stop event, not from
|
|
16
|
+
# docs. The real stdin payload is:
|
|
17
|
+
# {conversation_id, generation_id, model, status, loop_count, input_tokens,
|
|
18
|
+
# output_tokens, cache_read_tokens, cache_write_tokens, session_id,
|
|
19
|
+
# hook_event_name, cursor_version, workspace_roots[], user_email,
|
|
20
|
+
# transcript_path}
|
|
21
|
+
# Note `workspace_roots` — an ARRAY, and there is no `workspace_root_path` and
|
|
22
|
+
# no `cwd`. Reading a scalar here silently falls back to $PWD (whatever cwd the
|
|
23
|
+
# hook process inherited) and resolves the wrong project, or none.
|
|
24
|
+
#
|
|
25
|
+
# stdout — JSON; a `followup_message` field starts a new turn carrying that
|
|
26
|
+
# text (StopRequestResponse.followupMessage). Verified live: it does
|
|
27
|
+
# start turns, and each new turn ends and fires this hook again — a
|
|
28
|
+
# bare canary looped 5 times in 14s before Cursor's own loop_count
|
|
29
|
+
# cap stopped it. That is why the streak brake below is not optional.
|
|
30
|
+
# Never exit nonzero: a failing hook must not break the session.
|
|
31
|
+
#
|
|
32
|
+
# Hooks are loaded at STARTUP only. Dropping this file into ~/.cursor while a
|
|
33
|
+
# session is running does nothing until that session restarts (verified: the
|
|
34
|
+
# same canary was silent mid-session and fired immediately after a restart).
|
|
35
|
+
|
|
36
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
37
|
+
command -v curl >/dev/null 2>&1 || exit 0
|
|
38
|
+
|
|
39
|
+
INPUT=$(cat 2>/dev/null || echo '{}')
|
|
40
|
+
|
|
41
|
+
# workspace_roots[0] is what cursor-agent actually sends. The scalar spellings
|
|
42
|
+
# are accepted as fallbacks in case the payload shape changes again; $PWD is the
|
|
43
|
+
# last resort and is deliberately last, since it points at the hook process's
|
|
44
|
+
# inherited cwd rather than the agent's project.
|
|
45
|
+
PROJECT_CWD=$(echo "$INPUT" | jq -r '(.workspace_roots[0]? // .workspace_root_path? // .cwd?) // empty' 2>/dev/null || true)
|
|
46
|
+
if [ -z "$PROJECT_CWD" ] || [ "$PROJECT_CWD" = "null" ]; then
|
|
47
|
+
PROJECT_CWD="$PWD"
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
CONVERSATION=$(echo "$INPUT" | jq -r '.conversation_id // empty' 2>/dev/null || true)
|
|
51
|
+
[ -n "$CONVERSATION" ] || CONVERSATION="nocid"
|
|
52
|
+
|
|
53
|
+
# ── Resolve project-scoped .cursor/mcp.json ──────────────────────────────────
|
|
54
|
+
# PROJECT-scoped ONLY — walk up from the workspace root. A cursor-agent started
|
|
55
|
+
# outside a patchcord project is not that agent and must not be nudged.
|
|
56
|
+
CURSOR_MCP=""
|
|
57
|
+
dir="$PROJECT_CWD"
|
|
58
|
+
while [ "$dir" != "/" ] && [ -n "$dir" ]; do
|
|
59
|
+
if [ -f "$dir/.cursor/mcp.json" ]; then
|
|
60
|
+
CURSOR_MCP="$dir/.cursor/mcp.json"
|
|
61
|
+
break
|
|
62
|
+
fi
|
|
63
|
+
parent=$(dirname "$dir")
|
|
64
|
+
[ "$parent" = "$dir" ] && break
|
|
65
|
+
dir="$parent"
|
|
66
|
+
done
|
|
67
|
+
[ -n "$CURSOR_MCP" ] || exit 0
|
|
68
|
+
|
|
69
|
+
TOKEN=$(jq -r '.mcpServers.patchcord.headers.Authorization // empty' "$CURSOR_MCP" 2>/dev/null | sed 's/^Bearer //i' || true)
|
|
70
|
+
URL=$(jq -r '.mcpServers.patchcord.url // empty' "$CURSOR_MCP" 2>/dev/null || true)
|
|
71
|
+
if [ -z "$TOKEN" ] || [ -z "$URL" ]; then
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
# Cursor uses the bearer endpoint; both spellings normalize to the same base.
|
|
76
|
+
BASE_URL=$(echo "$URL" | sed 's|/mcp/bearer$||; s|/mcp$||')
|
|
77
|
+
|
|
78
|
+
# ── Suppression state, keyed per project+conversation ────────────────────────
|
|
79
|
+
# One file holding "<epoch> <consecutive_nudges>". Two jobs:
|
|
80
|
+
# * rate limit — never nudge more than once per COOLDOWN seconds
|
|
81
|
+
# * loop brake — if the agent is handed the same nudge MAX_CONSECUTIVE times
|
|
82
|
+
# without the inbox draining, stop; something is wrong and an
|
|
83
|
+
# endless followup chain would burn the session.
|
|
84
|
+
# Both reset the moment the inbox reaches zero.
|
|
85
|
+
#
|
|
86
|
+
# Keyed on the RESOLVED project root (the directory owning .cursor/mcp.json),
|
|
87
|
+
# never on the incoming workspace path: Cursor reports whatever cwd the turn ran
|
|
88
|
+
# in, so keying on that would mint a fresh key per subdirectory and silently
|
|
89
|
+
# defeat both the cooldown and the loop brake.
|
|
90
|
+
COOLDOWN=30
|
|
91
|
+
MAX_CONSECUTIVE=3
|
|
92
|
+
PROJECT_ROOT=$(dirname "$(dirname "$CURSOR_MCP")")
|
|
93
|
+
STATE_KEY=$(printf '%s|%s' "$PROJECT_ROOT" "$CONVERSATION" | cksum | cut -d' ' -f1)
|
|
94
|
+
STATE_FILE="/tmp/patchcord_cursor_stop_${STATE_KEY}"
|
|
95
|
+
|
|
96
|
+
# ── Pending count ────────────────────────────────────────────────────────────
|
|
97
|
+
BODY_FILE=$(mktemp "/tmp/patchcord_cursor_inbox.XXXXXX" 2>/dev/null || echo "/tmp/patchcord_cursor_inbox.$$")
|
|
98
|
+
HTTP_CODE=$(curl -s -o "$BODY_FILE" -w "%{http_code}" --max-time 5 \
|
|
99
|
+
-H "Authorization: Bearer ${TOKEN}" \
|
|
100
|
+
-H "x-patchcord-install-path: ${PROJECT_CWD}" \
|
|
101
|
+
"${BASE_URL}/api/inbox?status=pending&limit=5&count_only=1" 2>/dev/null || echo "000")
|
|
102
|
+
|
|
103
|
+
RESPONSE=$(cat "$BODY_FILE" 2>/dev/null || echo '{}')
|
|
104
|
+
rm -f "$BODY_FILE"
|
|
105
|
+
|
|
106
|
+
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "403" ]; then
|
|
107
|
+
# Surface a dead token once per cooldown rather than silently going quiet.
|
|
108
|
+
NOW=$(date +%s)
|
|
109
|
+
LAST=0
|
|
110
|
+
[ -f "$STATE_FILE" ] && LAST=$(cut -d' ' -f1 "$STATE_FILE" 2>/dev/null || echo 0)
|
|
111
|
+
if [ $(( NOW - LAST )) -ge "$COOLDOWN" ]; then
|
|
112
|
+
echo "$NOW 0" > "$STATE_FILE"
|
|
113
|
+
jq -n --arg code "$HTTP_CODE" \
|
|
114
|
+
'{followup_message: ("Patchcord token rejected (HTTP " + $code + "). Tell the human to re-run `npx patchcord` in this project — do not edit .cursor/mcp.json by hand.")}'
|
|
115
|
+
fi
|
|
116
|
+
exit 0
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
# Network blip / origin down — stay silent, try again next turn.
|
|
120
|
+
[ "$HTTP_CODE" = "200" ] || exit 0
|
|
121
|
+
|
|
122
|
+
COUNT=$(echo "$RESPONSE" | jq -r '.pending_count // .count // 0' 2>/dev/null || echo "0")
|
|
123
|
+
case "$COUNT" in
|
|
124
|
+
''|*[!0-9]*) COUNT=0 ;;
|
|
125
|
+
esac
|
|
126
|
+
|
|
127
|
+
if [ "$COUNT" -eq 0 ]; then
|
|
128
|
+
rm -f "$STATE_FILE"
|
|
129
|
+
exit 0
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
NOW=$(date +%s)
|
|
133
|
+
LAST=0
|
|
134
|
+
STREAK=0
|
|
135
|
+
if [ -f "$STATE_FILE" ]; then
|
|
136
|
+
LAST=$(cut -d' ' -f1 "$STATE_FILE" 2>/dev/null || echo 0)
|
|
137
|
+
STREAK=$(cut -d' ' -f2 "$STATE_FILE" 2>/dev/null || echo 0)
|
|
138
|
+
case "$LAST" in ''|*[!0-9]*) LAST=0 ;; esac
|
|
139
|
+
case "$STREAK" in ''|*[!0-9]*) STREAK=0 ;; esac
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
[ $(( NOW - LAST )) -lt "$COOLDOWN" ] && exit 0
|
|
143
|
+
[ "$STREAK" -ge "$MAX_CONSECUTIVE" ] && exit 0
|
|
144
|
+
|
|
145
|
+
echo "$NOW $(( STREAK + 1 ))" > "$STATE_FILE"
|
|
146
|
+
|
|
147
|
+
NAMESPACE=$(echo "$RESPONSE" | jq -r '.namespace_id // empty' 2>/dev/null || true)
|
|
148
|
+
AGENT_ID=$(echo "$RESPONSE" | jq -r '.agent_id // empty' 2>/dev/null || true)
|
|
149
|
+
|
|
150
|
+
if [ "$COUNT" -eq 1 ]; then
|
|
151
|
+
MSG="Patchcord: 1 pending message. Call the patchcord inbox tool now, do the work it asks for, then reply with what you did."
|
|
152
|
+
else
|
|
153
|
+
MSG="Patchcord: ${COUNT} pending messages. Call the patchcord inbox tool now, do the work each one asks for, then reply to each with what you did."
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
# Name the identity the count belongs to. This hook reads the token from
|
|
157
|
+
# .cursor/mcp.json on disk; the agent's MCP connection holds whatever token it
|
|
158
|
+
# was given at session start. Once anything rewrites that config the two drift
|
|
159
|
+
# apart, the hook counts for the new identity and the inbox tool answers 0 for
|
|
160
|
+
# the old one — a contradiction neither side can see alone. See
|
|
161
|
+
# lib/inbox-guard.sh.
|
|
162
|
+
if [ -n "$NAMESPACE" ] && [ -n "$AGENT_ID" ]; then
|
|
163
|
+
MSG="${MSG} These are addressed to ${AGENT_ID}@${NAMESPACE}. If the inbox tool reports a different identity, or reports 0 pending, this session is authenticated as another agent — the token on disk was replaced after it started. Do not keep retrying: say so and ask the user to restart the session."
|
|
164
|
+
fi
|
|
165
|
+
|
|
166
|
+
jq -n --arg msg "$MSG" '{followup_message: $msg}'
|
|
167
|
+
exit 0
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Shared stop-hook guards for the pending-message nudge.
|
|
3
|
+
#
|
|
4
|
+
# Sourced by check-inbox.sh (Claude Code), codex-stop-hook.sh and
|
|
5
|
+
# cursor-stop-hook.sh. Pure functions + a state file; no network, no jq
|
|
6
|
+
# dependency of its own.
|
|
7
|
+
#
|
|
8
|
+
# Why this exists — the stale-identity loop:
|
|
9
|
+
# A stop hook reads the bearer token from the config file ON DISK. The agent's
|
|
10
|
+
# MCP connection holds the token it was given when the session STARTED. Those
|
|
11
|
+
# are the same token right up until something rewrites the config — a
|
|
12
|
+
# re-provision, a team restore, a seat handover. From that moment the hook
|
|
13
|
+
# counts messages for the NEW identity while inbox() still queries as the OLD
|
|
14
|
+
# one, so the hook says "2 waiting" and inbox() truthfully answers 0. The
|
|
15
|
+
# agent has no way to see the contradiction, so it calls inbox() again, gets 0
|
|
16
|
+
# again, and burns turns until a human kills it.
|
|
17
|
+
#
|
|
18
|
+
# Neither side is wrong and neither can detect it alone. So the hook names the
|
|
19
|
+
# identity the count belongs to, which turns an invisible contradiction into
|
|
20
|
+
# something the agent can read and act on, and gives up after a few identical
|
|
21
|
+
# blocks instead of looping forever.
|
|
22
|
+
|
|
23
|
+
# pc_inbox_reason <count> <namespace> <agent> <inbox_call> <restart_hint>
|
|
24
|
+
#
|
|
25
|
+
# The nudge text. Names the identity so a session authenticated as a different
|
|
26
|
+
# agent can recognise the mismatch. Falls back to the plain wording when the
|
|
27
|
+
# server did not return an identity.
|
|
28
|
+
pc_inbox_reason() {
|
|
29
|
+
local count="$1" ns="$2" agent="$3" call="${4:-inbox()}" hint="${5:-restart this session}"
|
|
30
|
+
local noun="message(s)"
|
|
31
|
+
[ "$count" = "1" ] && noun="message"
|
|
32
|
+
|
|
33
|
+
if [ -z "$ns" ] || [ -z "$agent" ]; then
|
|
34
|
+
printf '%s patchcord %s waiting. Call %s and reply to all immediately.' \
|
|
35
|
+
"$count" "$noun" "$call"
|
|
36
|
+
return
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
printf '%s patchcord %s waiting for %s@%s. Call %s and reply to all immediately. If %s reports a different identity than %s@%s, or reports 0 pending, then this session is authenticated as another agent: the token on disk was replaced after the session started. Do NOT keep calling %s — say so and ask the user to %s so it reloads the config.' \
|
|
40
|
+
"$count" "$noun" "$agent" "$ns" \
|
|
41
|
+
"$call" \
|
|
42
|
+
"$call" "$agent" "$ns" \
|
|
43
|
+
"$call" "$hint"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# pc_streak_ok <state_file> <key> <max>
|
|
47
|
+
#
|
|
48
|
+
# Consecutive-identical-block breaker. `key` should encode identity + count, so
|
|
49
|
+
# the streak resets the moment either changes and a genuinely new message always
|
|
50
|
+
# gets through. Returns 0 (block) while the streak is under `max`, 1 (stay
|
|
51
|
+
# silent) once it is reached. Writes state best-effort; an unwritable /tmp
|
|
52
|
+
# degrades to "always block", never to a hard failure.
|
|
53
|
+
pc_streak_ok() {
|
|
54
|
+
local state="$1" key="$2" max="${3:-3}"
|
|
55
|
+
local prev="" prev_key="" prev_n=0
|
|
56
|
+
|
|
57
|
+
prev=$(cat "$state" 2>/dev/null || true)
|
|
58
|
+
if [ -n "$prev" ]; then
|
|
59
|
+
prev_key="${prev%|*}"
|
|
60
|
+
prev_n="${prev##*|}"
|
|
61
|
+
case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
if [ "$prev_key" = "$key" ]; then
|
|
65
|
+
if [ "$prev_n" -ge "$max" ]; then
|
|
66
|
+
return 1
|
|
67
|
+
fi
|
|
68
|
+
prev_n=$(( prev_n + 1 ))
|
|
69
|
+
else
|
|
70
|
+
prev_n=1
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# Braces so the REDIRECT's own failure is captured too — `printf > bad 2>/dev/null`
|
|
74
|
+
# still prints the redirect error, and kimi's hook treats stderr as the
|
|
75
|
+
# notification channel.
|
|
76
|
+
{ printf '%s|%s' "$key" "$prev_n" > "$state"; } 2>/dev/null || true
|
|
77
|
+
return 0
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# pc_state_path <prefix> <namespace> <agent>
|
|
81
|
+
# A filesystem-safe state path. Identity is part of the name so two agents in
|
|
82
|
+
# one project never share a streak counter.
|
|
83
|
+
pc_state_path() {
|
|
84
|
+
local prefix="$1" ns="${2:-nons}" agent="${3:-noagent}"
|
|
85
|
+
local slug
|
|
86
|
+
slug=$(printf '%s_%s' "$ns" "$agent" | tr -c 'A-Za-z0-9_.-' '_')
|
|
87
|
+
printf '/tmp/%s_%s' "$prefix" "$slug"
|
|
88
|
+
}
|
|
@@ -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.
|