patchcord 0.6.26 → 0.6.28

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.26",
4
+ "version": "0.6.28",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
package/bin/patchcord.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync, writeFileSync, unlinkSync, rmSync } from "fs";
3
+ import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync, writeFileSync, unlinkSync, rmSync, chmodSync } from "fs";
4
4
  import { join, dirname, basename } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { execSync } from "child_process";
@@ -14,6 +14,15 @@ const pluginRoot = join(__dirname, "..");
14
14
  import { resolveProjectBearer, harnessContext } from "../scripts/lib/resolve-project-bearer.mjs";
15
15
  const cmd = process.argv[2];
16
16
 
17
+ /** Write a file then chmod 0600 (token-bearing configs must not be group/world readable). */
18
+ function writeSecureFile(path, contents) {
19
+ // mode applies at creation (no umask window for fresh files); the explicit
20
+ // chmod also repairs pre-existing files that were created too permissive.
21
+ writeFileSync(path, contents, { mode: 0o600 });
22
+ try { chmodSync(path, 0o600); } catch {}
23
+ return path;
24
+ }
25
+
17
26
  if (cmd === "--version" || cmd === "-v") {
18
27
  const { readFileSync: _rf } = await import("fs");
19
28
  const { version } = JSON.parse(_rf(join(__dirname, "..", "package.json"), "utf-8"));
@@ -147,7 +156,8 @@ setup:
147
156
  patchcord --rename <name> [--agent-type <t>] rename this agent
148
157
 
149
158
  agent:
150
- patchcord whoami this agent's identity + project
159
+ patchcord whoami [--tool <harness>] [--json]
160
+ this agent's identity + project
151
161
  patchcord whoami --propose "<text>" set self-description (two-shot confirm)
152
162
  patchcord agents [name] list agents in your namespace, or show one
153
163
  patchcord subscribe [interval] [--kimi] realtime listener (Kimi: poll + exit)
@@ -166,12 +176,19 @@ team (run from the project root):
166
176
  patchcord provision <agent> --tool <X> --role <Y> --namespace <ns>
167
177
  [--dir <sub/>] [--project <name>] [--lead]
168
178
  create a worker agent (identity + config)
169
- patchcord pull <agent> --tool <X> --namespace <ns> [--dir <sub/>]
170
- move an existing agent into a folder
171
- (same identity, new token)
179
+ patchcord pull <agent> --tool <X> --namespace <ns> [--dir <sub/>] [--lead]
180
+ place an existing agent into a folder
181
+ (same identity; supersedes prior token
182
+ only ONE live credential per identity)
172
183
  patchcord provision revoke <agent> --namespace <ns>
173
184
  revoke a worker agent
174
- patchcord team list [--namespace <ns>] provisioned agents (server view)
185
+ patchcord team list [--namespace <ns>] [--json]
186
+ provisioned agents (server view)
187
+ patchcord team blueprint current --namespace <ns> [--json]
188
+ fetch current team blueprint revision
189
+ patchcord team blueprint push --namespace <ns> --file <blueprint.json> [--json]
190
+ create/update a team blueprint (first push
191
+ to a new namespace claims it — first-provision)
175
192
  patchcord team status reconcile folder ↔ identity ↔ tmux ↔ token
176
193
  patchcord team launch launch each worker in mux
177
194
 
@@ -612,12 +629,31 @@ async function _httpJSON(method, url, token, body) {
612
629
  // ── whoami ────────────────────────────────────────────────────
613
630
  if (cmd === "whoami") {
614
631
  const args = process.argv.slice(3);
615
- const found = await _resolveBearer();
632
+ const wantJson = args.includes("--json");
633
+ const toolIdx = args.indexOf("--tool");
634
+ const toolFlag = toolIdx !== -1 ? (args[toolIdx + 1] || "").trim() : "";
635
+ // Canonical harness aliases → installer tool slug for config resolution.
636
+ const CANON_TOOL = {
637
+ claude: "claude_code", claude_code: "claude_code",
638
+ codex: "codex", kimi: "kimi", "kimi-code": "kimi",
639
+ opencode: "opencode", agy: "antigravity", antigravity: "antigravity",
640
+ cursor: "cursor", grok: "grok", hermes: "hermes",
641
+ };
642
+ const resolveOpts = {};
643
+ if (toolFlag) {
644
+ const mapped = CANON_TOOL[toolFlag.toLowerCase()];
645
+ if (!mapped) {
646
+ console.error(`Unknown --tool '${toolFlag}'. Expected one of: ${Object.keys(CANON_TOOL).filter((k, i, a) => a.indexOf(k) === i).join(", ")}`);
647
+ process.exit(1);
648
+ }
649
+ resolveOpts.forceTool = mapped;
650
+ }
651
+ const found = await _resolveBearer(resolveOpts);
616
652
  if (!found) {
617
653
  console.error("No patchcord config found in current directory or any parent. Run `npx patchcord@latest` from a project directory first.");
618
654
  process.exit(1);
619
655
  }
620
- if (found.scope === "global") {
656
+ if (found.scope === "global" && !wantJson) {
621
657
  console.error(`\x1b[33m⚠ No project patchcord config in ${process.cwd()} or any parent.`);
622
658
  console.error(` Falling back to a GLOBAL config: ${found.configFile} (tool: ${found.tool}).`);
623
659
  console.error(` If this isn't the agent you expected, you're in a directory without its own .mcp.json —`);
@@ -669,19 +705,35 @@ if (cmd === "whoami") {
669
705
  process.exit(1);
670
706
  }
671
707
 
672
- // plain whoami (no flags)
708
+ // plain whoami (no propose)
673
709
  const { status, json, body } = await _httpJSON("GET", `${baseUrl}/api/agent/whoami`, token);
674
710
  if (status !== "200") {
675
711
  console.error(`✗ HTTP ${status}: ${(json && json.error) || body}`);
676
712
  process.exit(1);
677
713
  }
714
+ if (wantJson) {
715
+ const out = {
716
+ agent: json.agent,
717
+ namespace: json.namespace,
718
+ project_summary: json.project_summary ?? null,
719
+ whoami: json.whoami ?? null,
720
+ tool: found.tool || null,
721
+ config_file: found.configFile || null,
722
+ scope: found.scope || null,
723
+ };
724
+ process.stdout.write(JSON.stringify(out) + "\n");
725
+ process.exit(0);
726
+ }
678
727
  console.log(`agent: ${json.agent}`);
679
728
  console.log(`namespace: ${json.namespace}`);
680
729
  if (json.project_summary) console.log(`project: ${json.project_summary}`);
681
730
  if (json.whoami) {
682
731
  console.log(`self: ${json.whoami}`);
683
732
  } else {
684
- console.log(`self: (empty propose with: patchcord whoami --propose "<text>")`);
733
+ // Empty self-description is a NORMAL state for a healthy identity — a
734
+ // fresh mux-v2 seat once fail-closed reading "(empty …)" as "not
735
+ // provisioned". Say explicitly that identity is fine.
736
+ console.log(`self-description: (not set — optional; your identity ${json.agent}@${json.namespace} is fully provisioned. Set one with: patchcord whoami --propose "<text>")`);
685
737
  }
686
738
  console.log();
687
739
  console.log(`tip: \`patchcord agents\` returns whoami for all peers.`);
@@ -1135,7 +1187,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1135
1187
  });
1136
1188
  if (!token) { console.error("\n Login timed out or failed."); process.exit(1); }
1137
1189
  mkdirSync(dirname(AUTH_CONFIG), { recursive: true });
1138
- writeFileSync(AUTH_CONFIG, JSON.stringify({ token, baseUrl: DEFAULT_API }, null, 2) + "\n");
1190
+ writeSecureFile(AUTH_CONFIG, JSON.stringify({ token, baseUrl: DEFAULT_API }, null, 2) + "\n");
1139
1191
  console.log(` ${M.green}✓${M.rst} Logged in ${M.dim}(${AUTH_CONFIG})${M.rst}\n`);
1140
1192
  return { token, baseUrl: DEFAULT_API };
1141
1193
  };
@@ -1183,14 +1235,54 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1183
1235
  }
1184
1236
  return { ...r, m };
1185
1237
  };
1238
+
1239
+ /** After reminting into one harness file, update OTHER existing project
1240
+ * patchcord configs in the same dir so subscribe/hooks don't keep a
1241
+ * superseded pct- (dead .mcp.json next to live .cursor/mcp.json). */
1242
+ const syncSiblingPatchcordTokens = (dir, baseUrl, token, hostname, primaryTool) => {
1243
+ const hdr = { Authorization: `Bearer ${token}`, "X-Patchcord-Machine": hostname };
1244
+ const touchJson = (path, mutate) => {
1245
+ if (!existsSync(path)) return;
1246
+ try {
1247
+ let obj = {};
1248
+ try { obj = JSON.parse(readFileSync(path, "utf-8")); } catch { return; }
1249
+ // Only rewrite if a patchcord entry already exists — never invent configs.
1250
+ const before = JSON.stringify(obj);
1251
+ mutate(obj);
1252
+ if (JSON.stringify(obj) === before) return;
1253
+ writeSecureFile(path, JSON.stringify(obj, null, 2) + "\n");
1254
+ } catch {}
1255
+ };
1256
+ // Claude Code project config
1257
+ if (primaryTool !== "claude_code") {
1258
+ touchJson(join(dir, ".mcp.json"), (o) => {
1259
+ if (!o.mcpServers?.patchcord) return;
1260
+ o.mcpServers.patchcord = {
1261
+ ...(o.mcpServers.patchcord.type ? { type: o.mcpServers.patchcord.type } : { type: "http" }),
1262
+ url: `${baseUrl}/mcp/bearer`,
1263
+ headers: hdr,
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
+ };
1275
+
1186
1276
  const writeWorkerConfig = (tool, dir, baseUrl, token, hostname) => {
1187
1277
  mkdirSync(dir, { recursive: true });
1188
1278
  const hdr = { Authorization: `Bearer ${token}`, "X-Patchcord-Machine": hostname };
1279
+ // Centralized token-bearing config write: always chmod 0600 after write
1280
+ // (authoritative supersession — one live credential per identity).
1189
1281
  const writeJson = (p, mutate) => {
1190
1282
  let obj = {};
1191
1283
  try { obj = JSON.parse(readFileSync(p, "utf-8")); } catch {}
1192
1284
  mutate(obj);
1193
- writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
1285
+ writeSecureFile(p, JSON.stringify(obj, null, 2) + "\n");
1194
1286
  return p;
1195
1287
  };
1196
1288
  if (tool === "codex") {
@@ -1199,7 +1291,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1199
1291
  let ex = existsSync(p) ? readFileSync(p, "utf-8") : "";
1200
1292
  ex = ex.replace(/\[mcp_servers\.patchcord[\w.-]*\][^\[]*/g, "").replace(/\n{3,}/g, "\n\n").trim();
1201
1293
  ex = (ex ? ex + "\n\n" : "") + `[mcp_servers.patchcord-codex]\nurl = "${baseUrl}/mcp"\nhttp_headers = { "Authorization" = "Bearer ${token}", "X-Patchcord-Machine" = "${hostname}" }\ntool_timeout_sec = 300\n`;
1202
- writeFileSync(p, ex);
1294
+ writeSecureFile(p, ex);
1203
1295
  return p;
1204
1296
  }
1205
1297
  if (tool === "grok") {
@@ -1208,7 +1300,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1208
1300
  let ex = existsSync(p) ? readFileSync(p, "utf-8") : "";
1209
1301
  ex = ex.replace(/\[mcp_servers\.patchcord(?:[-\w]*)?\][\s\S]*?(?=\n\[(?!mcp_servers\.patchcord(?:\.|[-\w]*\]))|$)/g, "").replace(/\n{3,}/g, "\n\n").trim();
1210
1302
  ex = (ex ? ex + "\n\n" : "") + `[mcp_servers.patchcord]\nurl = "${baseUrl}/mcp"\nenabled = true\n\n[mcp_servers.patchcord.headers]\nAuthorization = "Bearer ${token}"\nX-Patchcord-Machine = "${hostname}"\n`;
1211
- writeFileSync(p, ex);
1303
+ writeSecureFile(p, ex);
1212
1304
  return p;
1213
1305
  }
1214
1306
  if (tool === "kimi" || tool === "kimi-code") {
@@ -1229,7 +1321,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1229
1321
  // Cursor IDE + Cursor CLI (cursor-agent) share the same project config —
1230
1322
  // same file, same schema (see patchcord.dev/console/connect writer).
1231
1323
  const cdir = join(dir, ".cursor"); mkdirSync(cdir, { recursive: true });
1232
- return writeJson(join(cdir, "mcp.json"), (o) => {
1324
+ const written = writeJson(join(cdir, "mcp.json"), (o) => {
1233
1325
  o.mcpServers = o.mcpServers || {};
1234
1326
  // NO "type" field — cursor-agent silently drops the entire server entry
1235
1327
  // when "type" is set to any value here (verified: tools simply never
@@ -1242,6 +1334,8 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1242
1334
  headers: hdr,
1243
1335
  };
1244
1336
  });
1337
+ syncSiblingPatchcordTokens(dir, baseUrl, token, hostname, tool);
1338
+ return written;
1245
1339
  }
1246
1340
  if (tool === "antigravity" || tool === "agy") {
1247
1341
  // Antigravity CLI: PROJECT-scoped config at .agents/mcp_config.json (one
@@ -1264,7 +1358,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1264
1358
  mkdirSync(dirname(hermesPath), { recursive: true });
1265
1359
  let existingYaml = "";
1266
1360
  try { existingYaml = existsSync(hermesPath) ? readFileSync(hermesPath, "utf-8") : ""; } catch {}
1267
- writeFileSync(hermesPath, upsertHermesConfig(existingYaml, `${baseUrl}/mcp`, token));
1361
+ writeSecureFile(hermesPath, upsertHermesConfig(existingYaml, `${baseUrl}/mcp`, token));
1268
1362
  // Install the Hermes patchcord skills (inbox/subscribe/wait) so the worker
1269
1363
  // knows the messaging flow, same as the installer does.
1270
1364
  try {
@@ -1280,10 +1374,12 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1280
1374
  // default: claude_code. type:"http" is REQUIRED — without it Claude Code
1281
1375
  // defaults to stdio transport and rejects the entry ("command: expected
1282
1376
  // string, received undefined").
1283
- return writeJson(join(dir, ".mcp.json"), (o) => {
1377
+ const primary = writeJson(join(dir, ".mcp.json"), (o) => {
1284
1378
  o.mcpServers = o.mcpServers || {};
1285
1379
  o.mcpServers.patchcord = { type: "http", url: `${baseUrl}/mcp`, headers: hdr };
1286
1380
  });
1381
+ syncSiblingPatchcordTokens(dir, baseUrl, token, hostname, tool);
1382
+ return primary;
1287
1383
  };
1288
1384
 
1289
1385
  if (cmd === "login") {
@@ -1388,17 +1484,20 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1388
1484
 
1389
1485
  if (cmd === "pull") {
1390
1486
  // Inverse of provision: place an EXISTING agent identity into a folder.
1391
- // Mints another token bound to the same agent_id@namespace (its original
1392
- // token is hashed server-side and unrecoverable, so we can't return the
1393
- // literal one but the identity is the same). Nothing is overwritten; the
1394
- // agent can now run from this folder too.
1487
+ // Server supersedes the prior active token for this identity (exactly ONE
1488
+ // live credential) and PRESERVES is_lead from that active token request
1489
+ // body --lead / role cannot escalate a worker. CLI --lead is typed
1490
+ // symmetry only; server is source of truth.
1395
1491
  const arg = process.argv[3];
1396
- if (!arg || arg.startsWith("-")) { console.error("Usage: patchcord pull <agent> --namespace ns --tool X [--dir sub/]"); process.exit(1); }
1492
+ if (!arg || arg.startsWith("-")) { console.error("Usage: patchcord pull <agent> --namespace ns --tool X [--dir sub/] [--lead]"); process.exit(1); }
1397
1493
  const tool = flagVal("tool", "claude_code");
1398
1494
  const ns = flagVal("namespace");
1399
1495
  const subdir = flagVal("dir", arg);
1496
+ const lead = process.argv.includes("--lead"); // typed symmetry; server ignores escalate-on-pull
1400
1497
  if (!ns) { console.error("--namespace <project-namespace> required"); process.exit(1); }
1401
- const { status, json, m } = await accountCall("POST", "/api/provision", { namespace_id: ns, agent_id: arg, tool, require_existing: true, label: `pull:${tool}` });
1498
+ const pullBody = { namespace_id: ns, agent_id: arg, tool, require_existing: true, label: `pull:${tool}` };
1499
+ if (lead) pullBody.is_lead = true; // server will still preserve, not escalate
1500
+ const { status, json, m } = await accountCall("POST", "/api/provision", pullBody);
1402
1501
  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); }
1403
1502
  if (status !== "200" || !json?.token) { console.error(`pull failed (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
1404
1503
  const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp$/, "");
@@ -1489,24 +1588,92 @@ you design the team, provision its agents, launch them, and manage them.
1489
1588
  if (cmd === "team") {
1490
1589
  const sub = process.argv[3];
1491
1590
  if (sub === "list") {
1591
+ const wantJson = process.argv.includes("--json");
1492
1592
  const { status, json } = await accountCall("GET", "/api/provision/list");
1493
1593
  if (status !== "200") { console.error(`list failed (HTTP ${status})`); process.exit(1); }
1494
1594
  // Dedup by namespace:agent — the server returns one row per token, so a
1495
1595
  // re-provisioned agent shows up many times. Optional --namespace filter.
1496
1596
  const filterNs = flagVal("namespace");
1497
1597
  const seen = new Set();
1498
- let n = 0;
1598
+ const rows = [];
1499
1599
  for (const a of (json?.agents || [])) {
1500
1600
  if (filterNs && a.namespace_id !== filterNs) continue;
1501
1601
  const key = `${a.namespace_id}:${a.agent_id}`;
1502
1602
  if (seen.has(key)) continue;
1503
1603
  seen.add(key);
1504
- console.log(` ${key} ${M.dim}${a.label || ""}${M.rst}`);
1505
- n++;
1604
+ rows.push({
1605
+ namespace_id: a.namespace_id,
1606
+ agent_id: a.agent_id,
1607
+ is_lead: Boolean(a.is_lead),
1608
+ tool: a.tool ?? null,
1609
+ harness: a.harness ?? a.tool ?? null,
1610
+ active: a.active !== false,
1611
+ label: a.label || null,
1612
+ });
1613
+ }
1614
+ if (wantJson) {
1615
+ process.stdout.write(JSON.stringify({ agents: rows }) + "\n");
1616
+ process.exit(0);
1506
1617
  }
1507
- if (!n) console.log(" (no agents)");
1618
+ for (const a of rows) {
1619
+ const leadTag = a.is_lead ? " lead" : "";
1620
+ const toolTag = a.tool ? ` ${a.tool}` : "";
1621
+ console.log(` ${a.namespace_id}:${a.agent_id} ${M.dim}${a.label || ""}${toolTag}${leadTag}${M.rst}`);
1622
+ }
1623
+ if (!rows.length) console.log(" (no agents)");
1508
1624
  process.exit(0);
1509
1625
  }
1626
+ if (sub === "blueprint") {
1627
+ const bsub = process.argv[4];
1628
+ if (bsub === "current") {
1629
+ const ns = flagVal("namespace");
1630
+ const wantJson = process.argv.includes("--json");
1631
+ if (!ns) { console.error("Usage: patchcord team blueprint current --namespace <ns> [--json]"); process.exit(1); }
1632
+ // pcm accountCall — cold fetch of GET /api/team/{ns}/blueprint/current
1633
+ const { status, json, body } = await accountCall("GET", `/api/team/${encodeURIComponent(ns)}/blueprint/current`);
1634
+ if (status !== "200") {
1635
+ console.error(`blueprint current failed (HTTP ${status}): ${(json && (json.error || json.detail)) || body || ""}`);
1636
+ process.exit(1);
1637
+ }
1638
+ if (wantJson) {
1639
+ // JSON-only stdout when --json (stable machine contract).
1640
+ process.stdout.write(JSON.stringify(json) + "\n");
1641
+ process.exit(0);
1642
+ }
1643
+ console.log(`revision_uid: ${json.revision_uid}`);
1644
+ console.log(`sha256: ${json.sha256}`);
1645
+ console.log(`canonical_json: ${JSON.stringify(json.canonical_json)}`);
1646
+ process.exit(0);
1647
+ }
1648
+ if (bsub === "push") {
1649
+ const ns = flagVal("namespace");
1650
+ const file = flagVal("file");
1651
+ const idem = flagVal("idempotency-key");
1652
+ const wantJson = process.argv.includes("--json");
1653
+ if (!ns || !file) { console.error("Usage: patchcord team blueprint push --namespace <ns> --file <blueprint.json> [--idempotency-key k] [--json]"); process.exit(1); }
1654
+ let doc;
1655
+ try { doc = JSON.parse(readFileSync(file, "utf-8")); }
1656
+ catch (e) { console.error(`cannot read blueprint file: ${e.message}`); process.exit(1); }
1657
+ // First POST for an unowned namespace IS first-provision (server claims
1658
+ // the namespace atomically when PATCHCORD_BLUEPRINT_ATOMIC_CREATE is on).
1659
+ const payload = { canonical_json: doc };
1660
+ if (idem) payload.idempotency_key = idem;
1661
+ const { status, json, body } = await accountCall("POST", `/api/team/${encodeURIComponent(ns)}/blueprint/revisions`, payload);
1662
+ if (status !== "201" && status !== "200") {
1663
+ console.error(`blueprint push failed (HTTP ${status}): ${(json && (json.error || JSON.stringify(json.detail))) || body || ""}`);
1664
+ process.exit(1);
1665
+ }
1666
+ if (wantJson) {
1667
+ process.stdout.write(JSON.stringify({ ...json, created: status === "201" }) + "\n");
1668
+ process.exit(0);
1669
+ }
1670
+ console.log(`${status === "201" ? "created" : "unchanged (dedup)"} revision_uid: ${json.revision_uid}`);
1671
+ console.log(`sha256: ${json.sha256}`);
1672
+ process.exit(0);
1673
+ }
1674
+ console.error("Usage: patchcord team blueprint <current|push> --namespace <ns> [--json]");
1675
+ process.exit(1);
1676
+ }
1510
1677
  if (sub === "status") {
1511
1678
  // Reconciler: folder ↔ patchcord identity ↔ tmux session ↔ token-active.
1512
1679
  // Truth = .patchcord/team.json. For each agent we resolve the token from
@@ -1643,7 +1810,7 @@ you design the team, provision its agents, launch them, and manage them.
1643
1810
  }
1644
1811
  process.exit(0);
1645
1812
  }
1646
- console.error("Usage: patchcord team <list|launch|status>");
1813
+ console.error("Usage: patchcord team <list|blueprint|launch|status>");
1647
1814
  process.exit(1);
1648
1815
  }
1649
1816
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.26",
3
+ "version": "0.6.28",
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"
@@ -131,7 +131,12 @@ const kimiCodeReader = (cwd) => readJsonAt(join(cwd, ".kimi-code", "mcp.json"),
131
131
 
132
132
  /** Detect which harness launched this process (env + explicit overrides). */
133
133
  export function harnessContext(env = process.env, options = {}) {
134
- const forceTool = (env.PATCHCORD_FORCE_TOOL || env.PATCHCORD_TOOL || "").toLowerCase();
134
+ const forceTool = (
135
+ options.forceTool
136
+ || env.PATCHCORD_FORCE_TOOL
137
+ || env.PATCHCORD_TOOL
138
+ || ""
139
+ ).toLowerCase();
135
140
  const preferKimi =
136
141
  options.preferKimi === true
137
142
  || forceTool === "kimi"
@@ -148,6 +153,17 @@ export function harnessContext(env = process.env, options = {}) {
148
153
 
149
154
  /** Ordered per-project readers for the active harness. */
150
155
  export function projectReadersForContext(ctx) {
156
+ const byTool = {
157
+ claude_code: claudeReader,
158
+ cursor: cursorReader,
159
+ vscode: vscodeReader,
160
+ opencode: opencodeReader,
161
+ antigravity: antigravityReader,
162
+ agy: antigravityReader,
163
+ grok: grokReader,
164
+ codex: codexReader,
165
+ kimi: kimiCodeReader,
166
+ };
151
167
  const defaultReaders = [
152
168
  claudeReader,
153
169
  cursorReader,
@@ -159,6 +175,17 @@ export function projectReadersForContext(ctx) {
159
175
  ];
160
176
  const kimiReaders = [kimiReader, kimiCodeReader];
161
177
 
178
+ // Explicit --tool / forceTool: prefer that harness's config first.
179
+ if (ctx.forceTool && byTool[ctx.forceTool]) {
180
+ const forced = byTool[ctx.forceTool];
181
+ const rest = [...defaultReaders, ...kimiReaders].filter((r) => r !== forced);
182
+ // kimi has two readers — prefer both when forcing kimi
183
+ if (ctx.forceTool === "kimi") {
184
+ return [...kimiReaders, ...defaultReaders];
185
+ }
186
+ return [forced, ...rest];
187
+ }
188
+
162
189
  if (ctx.preferKimi) {
163
190
  return [...kimiReaders, ...defaultReaders];
164
191
  }
@@ -179,22 +206,34 @@ export function projectReadersForContext(ctx) {
179
206
  return [...defaultReaders, ...kimiReaders];
180
207
  }
181
208
 
182
- /** Walk up from startDir; return first matching project bearer or null. */
183
- export function resolveProjectBearer(startDir, options = {}) {
209
+ /**
210
+ * All project bearers found at startDir (and ancestors), in harness preference
211
+ * order, de-duplicated by token. Used by subscribe to skip superseded configs
212
+ * (e.g. dead .mcp.json next to a live .cursor/mcp.json after a Cursor remint).
213
+ */
214
+ export function listProjectBearers(startDir, options = {}) {
184
215
  const ctx = harnessContext(process.env, options);
185
216
  const readers = projectReadersForContext(ctx);
217
+ const out = [];
218
+ const seen = new Set();
186
219
  let dir = startDir;
187
220
  while (dir && dir !== "/") {
188
221
  for (const r of readers) {
189
222
  const found = r(dir);
190
- if (found) {
191
- found.scope = "project";
192
- return found;
193
- }
223
+ if (!found?.token || seen.has(found.token)) continue;
224
+ seen.add(found.token);
225
+ found.scope = "project";
226
+ out.push(found);
194
227
  }
195
228
  const parent = dirname(dir);
196
229
  if (parent === dir) break;
197
230
  dir = parent;
198
231
  }
199
- return null;
232
+ return out;
233
+ }
234
+
235
+ /** Walk up from startDir; return first matching project bearer or null. */
236
+ export function resolveProjectBearer(startDir, options = {}) {
237
+ const listed = listProjectBearers(startDir, options);
238
+ return listed[0] || null;
200
239
  }
@@ -13,7 +13,7 @@ import { request as httpRequest } from "node:http";
13
13
  import { URL } from "node:url";
14
14
  import { dirname } from "node:path";
15
15
  import { connect as wsConnect } from "./lib/ws.mjs";
16
- import { resolveProjectBearer } from "./lib/resolve-project-bearer.mjs";
16
+ import { resolveProjectBearer, listProjectBearers } from "./lib/resolve-project-bearer.mjs";
17
17
 
18
18
  // --- Hermes webhook bridge mode -------------------------------------------
19
19
  // Default mode writes "PATCHCORD: ..." lines to stdout for Claude Code's
@@ -69,17 +69,76 @@ function die(msg, code = 1) {
69
69
  process.exit(code);
70
70
  }
71
71
 
72
+ function errDetail(e) {
73
+ // Prefer message; fall back to code / String so "pending heartbeat failed: "
74
+ // with an empty message is never silent again (socket hang up / 522 / etc.).
75
+ if (!e) return "unknown";
76
+ const msg = e.message || e.code || "";
77
+ const extra = e.code && e.message && e.code !== e.message ? ` code=${e.code}` : "";
78
+ return `${msg || String(e)}${extra}`;
79
+ }
80
+
72
81
  function readMcpConfig(cwd) {
73
82
  // Prefer env vars injected by patchcord.mjs, which already ran _resolveBearer()
74
83
  // and supports all tool configs with harness-aware ordering.
75
84
  if (process.env.PATCHCORD_BASE_URL && process.env.PATCHCORD_BEARER_TOKEN) {
76
- return { baseUrl: process.env.PATCHCORD_BASE_URL, token: process.env.PATCHCORD_BEARER_TOKEN };
85
+ return { baseUrl: process.env.PATCHCORD_BASE_URL, token: process.env.PATCHCORD_BEARER_TOKEN, source: "env" };
77
86
  }
78
87
  const found = resolveProjectBearer(cwd);
79
88
  if (!found) {
80
89
  die(`no patchcord config found — run from a project directory or use 'patchcord subscribe'`);
81
90
  }
82
- return { baseUrl: found.baseUrl, token: found.token };
91
+ return { baseUrl: found.baseUrl, token: found.token, source: found.configFile || "project" };
92
+ }
93
+
94
+ /** Try every on-disk project bearer until /api/realtime/ticket returns 200.
95
+ * Skips superseded tokens left behind when one harness reminted and another
96
+ * config file still holds the old pct- (classic: dead .mcp.json + live .cursor). */
97
+ async function resolveLiveConfig(cwd) {
98
+ if (process.env.PATCHCORD_BASE_URL && process.env.PATCHCORD_BEARER_TOKEN) {
99
+ const cfg = {
100
+ baseUrl: process.env.PATCHCORD_BASE_URL,
101
+ token: process.env.PATCHCORD_BEARER_TOKEN,
102
+ source: "env",
103
+ };
104
+ // Still validate — env can go stale after a remint too.
105
+ const res = await httpJson(`${cfg.baseUrl}/api/realtime/ticket`, {
106
+ headers: { Authorization: `Bearer ${cfg.token}`, "x-patchcord-install-path": cwd },
107
+ });
108
+ if (res.status === 200) return cfg;
109
+ if (res.status !== 401 && res.status !== 403) {
110
+ throw new Error(`ticket HTTP ${res.status}: ${res.body.slice(0, 200)}`);
111
+ }
112
+ logErr(`subscribe: env token rejected (HTTP ${res.status}); scanning project configs`);
113
+ }
114
+ const candidates = listProjectBearers(cwd);
115
+ if (!candidates.length) {
116
+ die(`no patchcord config found — run from a project directory or use 'patchcord subscribe'`);
117
+ }
118
+ const rejected = [];
119
+ for (const c of candidates) {
120
+ const res = await httpJson(`${c.baseUrl}/api/realtime/ticket`, {
121
+ headers: { Authorization: `Bearer ${c.token}`, "x-patchcord-install-path": cwd },
122
+ });
123
+ if (res.status === 200) {
124
+ if (rejected.length) {
125
+ logErr(`subscribe: using live token from ${c.configFile} (skipped ${rejected.length} superseded)`);
126
+ }
127
+ return { baseUrl: c.baseUrl, token: c.token, source: c.configFile || "project" };
128
+ }
129
+ if (res.status === 401 || res.status === 403) {
130
+ rejected.push(`${c.configFile || c.tool}:${res.status}`);
131
+ continue;
132
+ }
133
+ if (res.status === 501) {
134
+ die("ticket: server not configured for realtime (self-hosted without Supabase?)");
135
+ }
136
+ if (res.status === 404) {
137
+ die("ticket: namespace not owned — regenerate your token");
138
+ }
139
+ throw new Error(`ticket HTTP ${res.status}: ${res.body.slice(0, 200)}`);
140
+ }
141
+ die(`ticket: all project tokens rejected (${rejected.join(", ") || "none"}) — remint with patchcord pull/provision`);
83
142
  }
84
143
 
85
144
  function httpJson(urlStr, { method = "GET", headers = {}, body = null } = {}) {
@@ -133,7 +192,7 @@ async function notify(line, meta = {}) {
133
192
  }
134
193
  }
135
194
 
136
- async function fetchTicket(baseUrl, token) {
195
+ async function fetchTicket(baseUrl, token, { fatalAuth = true } = {}) {
137
196
  const res = await httpJson(`${baseUrl}/api/realtime/ticket`, {
138
197
  headers: {
139
198
  Authorization: `Bearer ${token}`,
@@ -141,7 +200,12 @@ async function fetchTicket(baseUrl, token) {
141
200
  },
142
201
  });
143
202
  if (res.status === 401 || res.status === 403) {
144
- die(`ticket: token rejected (HTTP ${res.status}) — check .mcp.json`);
203
+ if (fatalAuth) {
204
+ die(`ticket: token rejected (HTTP ${res.status}) — check .mcp.json / .cursor/mcp.json`);
205
+ }
206
+ const err = new Error(`ticket HTTP ${res.status}`);
207
+ err.status = res.status;
208
+ throw err;
145
209
  }
146
210
  if (res.status === 501) {
147
211
  die("ticket: server not configured for realtime (self-hosted without Supabase?)");
@@ -165,22 +229,49 @@ async function fetchTicket(baseUrl, token) {
165
229
  // inbox() manually. Emit a stdout line when there's a pending queue so
166
230
  // Monitor wakes the agent the same way a real arrival does.
167
231
  async function drainQueueOnce(baseUrl, token) {
168
- const res = await httpJson(`${baseUrl}/api/inbox?count_only=1&limit=100`, {
169
- headers: {
170
- Authorization: `Bearer ${token}`,
171
- "x-patchcord-install-path": process.cwd(),
172
- },
173
- });
174
- if (res.status !== 200) {
175
- throw new Error(`inbox HTTP ${res.status}`);
176
- }
177
- let count = 0;
178
- try {
179
- count = JSON.parse(res.body).pending_count ?? 0;
180
- } catch (_) {}
181
- if (count > 0) {
182
- await notify(`PATCHCORD: ${count} waiting in inbox`, { count, kind: "pending" });
232
+ const maxAttempts = 3;
233
+ let lastErr;
234
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
235
+ try {
236
+ const res = await httpJson(`${baseUrl}/api/inbox?count_only=1&limit=100`, {
237
+ headers: {
238
+ Authorization: `Bearer ${token}`,
239
+ "x-patchcord-install-path": process.cwd(),
240
+ },
241
+ });
242
+ if (res.status === 401 || res.status === 403) {
243
+ const err = new Error(`inbox HTTP ${res.status}`);
244
+ err.status = res.status;
245
+ throw err;
246
+ }
247
+ if (res.status !== 200) {
248
+ // 522/502/503 from Cloudflare when origin blips — retry.
249
+ if (res.status >= 500 && attempt < maxAttempts) {
250
+ await new Promise((r) => setTimeout(r, 500 * attempt));
251
+ continue;
252
+ }
253
+ throw new Error(`inbox HTTP ${res.status}`);
254
+ }
255
+ let count = 0;
256
+ try {
257
+ count = JSON.parse(res.body).pending_count ?? 0;
258
+ } catch (_) {}
259
+ if (count > 0) {
260
+ await notify(`PATCHCORD: ${count} waiting in inbox`, { count, kind: "pending" });
261
+ }
262
+ return;
263
+ } catch (e) {
264
+ lastErr = e;
265
+ if (e?.status === 401 || e?.status === 403) throw e;
266
+ const retryable = !e?.status || e.status >= 500 || e.code === "ECONNRESET" || e.code === "ETIMEDOUT" || /socket hang up/i.test(e?.message || "");
267
+ if (retryable && attempt < maxAttempts) {
268
+ await new Promise((r) => setTimeout(r, 500 * attempt));
269
+ continue;
270
+ }
271
+ throw e;
272
+ }
183
273
  }
274
+ throw lastErr;
184
275
  }
185
276
 
186
277
  function writePidfile(path) {
@@ -225,8 +316,8 @@ function removePidfile(path) {
225
316
 
226
317
  async function run() {
227
318
  const cwd = process.cwd();
228
- const { baseUrl, token } = readMcpConfig(cwd);
229
- logErr(`subscribe: cwd=${cwd} server=${baseUrl}`);
319
+ let { baseUrl, token, source } = await resolveLiveConfig(cwd);
320
+ logErr(`subscribe: cwd=${cwd} server=${baseUrl} auth=${source}`);
230
321
 
231
322
  if (HERMES_MODE) {
232
323
  if (!HERMES_WEBHOOK) {
@@ -277,25 +368,41 @@ async function run() {
277
368
 
278
369
  let backoffIdx = 0;
279
370
 
371
+ const refreshAuth = async () => {
372
+ try {
373
+ return await fetchTicket(baseUrl, token, { fatalAuth: false });
374
+ } catch (e) {
375
+ if (e?.status === 401 || e?.status === 403) {
376
+ // Bearer was superseded mid-flight (remint into another harness file).
377
+ // Re-scan disk for a live token before giving up.
378
+ logErr(`subscribe: bearer rejected on refresh — re-scanning configs`);
379
+ const live = await resolveLiveConfig(cwd);
380
+ baseUrl = live.baseUrl;
381
+ token = live.token;
382
+ source = live.source;
383
+ logErr(`subscribe: switched auth to ${source}`);
384
+ return await fetchTicket(baseUrl, token, { fatalAuth: false });
385
+ }
386
+ throw e;
387
+ }
388
+ };
389
+
280
390
  const loop = async () => {
281
391
  while (true) {
282
392
  try {
283
- await runOnce(ticket, baseUrl, token, async () => {
284
- ticket = await fetchTicket(baseUrl, token);
285
- return ticket;
286
- });
393
+ await runOnce(ticket, baseUrl, () => token, refreshAuth);
287
394
  backoffIdx = 0; // clean disconnect resets backoff
288
395
  } catch (e) {
289
- logErr(`subscribe: ${e.message}`);
396
+ logErr(`subscribe: ${errDetail(e)}`);
290
397
  }
291
398
  const delay = RECONNECT_BACKOFF_MS[Math.min(backoffIdx, RECONNECT_BACKOFF_MS.length - 1)];
292
399
  backoffIdx++;
293
400
  logErr(`subscribe: reconnecting in ${delay}ms`);
294
401
  await new Promise((r) => setTimeout(r, delay));
295
402
  try {
296
- ticket = await fetchTicket(baseUrl, token);
403
+ ticket = await refreshAuth();
297
404
  } catch (e) {
298
- logErr(`subscribe: ticket refresh failed: ${e.message}`);
405
+ logErr(`subscribe: ticket refresh failed: ${errDetail(e)}`);
299
406
  }
300
407
  }
301
408
  };
@@ -303,7 +410,7 @@ async function run() {
303
410
  await loop();
304
411
  }
305
412
 
306
- function runOnce(ticket, baseUrl, token, refreshTicket) {
413
+ function runOnce(ticket, baseUrl, getToken, refreshTicket) {
307
414
  return new Promise((resolve, reject) => {
308
415
  const allowedNs = new Set(ticket.namespace_ids);
309
416
  const wsUrl = `${ticket.realtime_url}?apikey=${encodeURIComponent(ticket.apikey)}&vsn=1.0.0`;
@@ -354,8 +461,8 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
354
461
  // otherwise wake the agent. Fire-and-forget: a transient HTTP
355
462
  // failure here just means we miss queued messages this round;
356
463
  // the next reconnect retries.
357
- drainQueueOnce(baseUrl, token).catch((e) => {
358
- logErr(`subscribe: queue check failed: ${e.message}`);
464
+ drainQueueOnce(baseUrl, getToken()).catch((e) => {
465
+ logErr(`subscribe: queue check failed: ${errDetail(e)}`);
359
466
  });
360
467
  heartbeatTimer = setInterval(() => {
361
468
  try {
@@ -392,8 +499,8 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
392
499
  // inbox is non-empty so a later idle tick has another chance to
393
500
  // wake the agent. drainQueueOnce stays silent if pending_count == 0.
394
501
  pendingHeartbeatTimer = setInterval(() => {
395
- drainQueueOnce(baseUrl, token).catch((e) => {
396
- logErr(`subscribe: pending heartbeat failed: ${e.message}`);
502
+ drainQueueOnce(baseUrl, getToken()).catch((e) => {
503
+ logErr(`subscribe: pending heartbeat failed: ${errDetail(e)}`);
397
504
  });
398
505
  }, PENDING_HEARTBEAT_MS);
399
506
 
@@ -438,7 +545,7 @@ function runOnce(ticket, baseUrl, token, refreshTicket) {
438
545
  // Transient network/server error — do NOT close the live
439
546
  // connection. The current JWT is still valid for ~2 more min
440
547
  // (JWT_REFRESH_SAFETY_MARGIN_SEC). Retry sooner.
441
- logErr(`subscribe: token refresh failed, retrying in 30s: ${e.message}`);
548
+ logErr(`subscribe: token refresh failed, retrying in 30s: ${errDetail(e)}`);
442
549
  refreshTimer = setTimeout(doRefresh, 30_000);
443
550
  }
444
551
  };