patchcord 0.6.34 → 0.6.36

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.34",
4
+ "version": "0.6.36",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
package/bin/patchcord.mjs CHANGED
@@ -222,14 +222,29 @@ team (run from the project root):
222
222
  patchcord team status reconcile folder ↔ identity ↔ tmux ↔ token
223
223
  patchcord team launch launch each worker in mux
224
224
 
225
- projects (cloud-only — a free-text label grouping your own namespaces):
226
- patchcord namespace set-project <namespace> <project> assign a project label
227
- patchcord namespace set-project <namespace> clear it
225
+ projects (cloud-only — groups your own namespaces into one initiative.
226
+ NOT just a label: the LEADS of Teams in one project can message each other
227
+ with no handshake. Any NON-lead still needs an approved link, below.):
228
+ patchcord project list projects + their Teams
229
+ patchcord project create <name> explicit; nothing else creates one
230
+ patchcord project rename <project-id> <new-name> label only; reach unchanged
231
+ patchcord project rm <project-id> Teams survive, un-grouped
232
+ patchcord namespace set-project <ns> <project> assign (project must exist)
233
+ patchcord namespace set-project <ns> <project> --create create it, then assign
234
+ patchcord namespace set-project <ns> un-group it
235
+ patchcord provision ... --project <name> [--create-project]
236
+ patchcord orchestrator ... --project <name> [--create-project]
237
+
238
+ A namespace in NO project has no cross-team peers at all — that is the
239
+ default and it is deliberate. Assignment never invents a project: an
240
+ unknown name is an error, because silently creating one on a typo made
241
+ mistyped projects invisible and unmanageable.
228
242
 
229
243
  cross-namespace links (cloud-only, no CLI — agent-to-agent via MCP:
230
244
  request_namespace_link(ns, agent) / respond_namespace_link(ns, agent, approve)
231
- / list_my_namespace_links. Any agent, no lead role — but each link is to ONE
232
- named agent, never the whole namespace; opt-in, not project-based.)
245
+ / list_my_namespace_links. Any agent, no lead role — each link is to ONE
246
+ named agent, never the whole namespace. This is the ONLY way to reach a
247
+ non-lead in another namespace, project or no project.)
233
248
 
234
249
  schedules:
235
250
  patchcord schedule create <name> --namespace <ns> --to <agent> --content "..."
@@ -1169,7 +1184,7 @@ if (cmd === "subscribe") {
1169
1184
  // account token (user-level, tied to NO agent) lives at ~/.patchcord/auth.json
1170
1185
  // (legacy: main.json / master.json) or $PATCHCORD_TOKEN (legacy: *_MAIN/MASTER).
1171
1186
  // `patchcord login` authenticates; everything else logs in on demand.
1172
- if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "provision" || cmd === "pull" || cmd === "team" || cmd === "schedule" || cmd === "namespace") {
1187
+ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "provision" || cmd === "pull" || cmd === "team" || cmd === "schedule" || cmd === "namespace" || cmd === "project" || cmd === "projects") {
1173
1188
  const M = { cyan: "\x1b[36m", green: "\x1b[32m", dim: "\x1b[2m", rst: "\x1b[0m" };
1174
1189
  const AUTH_CONFIG = join(HOME, ".patchcord", "auth.json");
1175
1190
  const LEGACY_CONFIGS = [join(HOME, ".patchcord", "main.json"), join(HOME, ".patchcord", "master.json")];
@@ -1195,6 +1210,42 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1195
1210
  const forgetAuth = () => {
1196
1211
  for (const p of [AUTH_CONFIG, ...LEGACY_CONFIGS]) { try { unlinkSync(p); } catch {} }
1197
1212
  };
1213
+ // Assign a namespace to a project, shared by `provision --project` and
1214
+ // `orchestrator --project`.
1215
+ //
1216
+ // Assignment RESOLVES an existing project and NEVER creates one. The server
1217
+ // used to create a project for any name it did not recognise, so a typo
1218
+ // silently produced a new, invisible project — the defect that got an earlier
1219
+ // version of this feature abandoned. `--create-project` makes creation an
1220
+ // explicit act rather than a side effect of misspelling.
1221
+ //
1222
+ // warnOnly: a failure here must not fail the whole command. The agent
1223
+ // identity is already provisioned and the credential is already on disk;
1224
+ // exiting non-zero would make a working identity look like a failed one.
1225
+ const assignProject = async (ns, project, { warnOnly = false, label = "done" } = {}) => {
1226
+ if (process.argv.includes("--create-project")) {
1227
+ const { status: cStatus, json: cJson } = await accountCall("POST", "/api/projects", { name: project });
1228
+ if (cStatus === "201") console.log(` ${M.dim}created project "${project}"${M.rst}`);
1229
+ else if (cStatus !== "409") {
1230
+ console.error(` ${M.dim}warning: could not create project "${project}" (HTTP ${cStatus}): ${cJson?.error || ""}${M.rst}`);
1231
+ }
1232
+ }
1233
+ const { status, json } = await accountCall("POST", "/api/namespace/set-project", { namespace_id: ns, project });
1234
+ if (status === "200") {
1235
+ console.log(` ${M.dim}namespace ${ns} assigned to project "${project}" — its lead can now reach other leads in that project${M.rst}`);
1236
+ return true;
1237
+ }
1238
+ if (status === "404") {
1239
+ console.error(` ${M.dim}warning: ${label}, but there is no project named "${project}".${M.rst}`);
1240
+ console.error(` ${M.dim} create it: patchcord project create ${project}${M.rst}`);
1241
+ console.error(` ${M.dim} or re-run with --create-project${M.rst}`);
1242
+ } else {
1243
+ const retry = json?.retryable ? " (transient — retry shortly, your login is fine)" : "";
1244
+ console.error(` ${M.dim}warning: ${label}, but --project failed (HTTP ${status}): ${json?.error || ""}${retry}${M.rst}`);
1245
+ }
1246
+ if (!warnOnly) process.exit(1);
1247
+ return false;
1248
+ };
1198
1249
  const flagVal = (name, def = "") => {
1199
1250
  const eq = process.argv.find((a) => a.startsWith(`--${name}=`));
1200
1251
  if (eq) return eq.split("=").slice(1).join("=");
@@ -1504,69 +1555,248 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1504
1555
  writeFileSync(tj, JSON.stringify(man, null, 2) + "\n");
1505
1556
  }
1506
1557
  } catch {}
1507
- // --project assigns this namespace to a project an organizational label
1508
- // ONLY, does not affect cross-namespace messaging (that's namespace_links,
1509
- // agent-to-agent, no CLI). Separate call: provisioning creates the agent
1510
- // identity, project assignment groups the NAMESPACE safe to retry
1511
- // independently, and a namespace already in a project just gets reassigned
1512
- // (last call wins, server-side upsert).
1558
+ // --project assigns this namespace to a project: a grouping entity above
1559
+ // the namespace. It DOES affect cross-namespace messaging Teams in one
1560
+ // project get a standing LEAD-TO-LEAD channel with no handshake. Reaching a
1561
+ // NON-lead elsewhere still needs an approved namespace_links pair.
1562
+ //
1563
+ // Separate call: provisioning creates the agent identity, project assignment
1564
+ // groups the NAMESPACE — safe to retry independently. Assignment RESOLVES an
1565
+ // existing project and never creates one, so an unknown name is a 404, not a
1566
+ // new project; pass --create-project to create it first, deliberately.
1513
1567
  if (project) {
1514
- const { status: pStatus, json: pJson } = await accountCall("POST", "/api/namespace/set-project", { namespace_id: ns, project });
1515
- if (pStatus !== "200") {
1516
- console.error(` ${M.dim}warning: provisioned OK, but --project failed (HTTP ${pStatus}): ${pJson?.error || ""}${M.rst}`);
1517
- } else {
1518
- console.log(` ${M.dim}namespace ${ns} assigned to project "${project}"${M.rst}`);
1519
- }
1568
+ await assignProject(ns, project, { warnOnly: true, label: "provisioned OK" });
1520
1569
  }
1521
1570
  console.log(`✓ provisioned ${M.green}${ns}:${arg}${M.rst} [${tool}${role ? "/" + role : ""}${lead ? "/lead" : ""}] → ${dir}`);
1522
1571
  process.exit(0);
1523
1572
  }
1524
1573
 
1525
1574
  if (cmd === "namespace") {
1526
- // Cloud-only: group namespaces into a "project" — a free-text organizational
1527
- // label only. Does NOT gate cross-namespace lead chat that's decided by
1528
- // per-pair namespace_links, approved lead-to-lead via the MCP tools
1529
- // request_namespace_link / respond_namespace_link (no CLI for that; see
1530
- // --help's CROSS-NAMESPACE LINKS section).
1575
+ // Cloud-only: group namespaces into a PROJECT — a grouping entity above the
1576
+ // namespace. It is NOT merely a label: Teams in one project get a standing
1577
+ // LEAD-TO-LEAD message channel with no per-pair handshake. Reaching any
1578
+ // NON-lead in another namespace still requires an approved namespace_links
1579
+ // pair, agent-to-agent via the MCP tools request_namespace_link /
1580
+ // respond_namespace_link (no CLI for that; see --help's CROSS-NAMESPACE
1581
+ // LINKS section).
1531
1582
  const sub = process.argv[3];
1532
1583
  if (sub === "set-project") {
1533
1584
  const ns = process.argv[4];
1534
- const project = process.argv[5];
1535
- if (!ns) { console.error("Usage: patchcord namespace set-project <namespace> [project-name]\n Omit project-name to clear the label."); process.exit(1); }
1585
+ const project = (process.argv[5] || "").startsWith("-") ? "" : (process.argv[5] || "");
1586
+ const doCreate = process.argv.includes("--create");
1587
+ if (!ns) { console.error("Usage: patchcord namespace set-project <namespace> [project-name] [--create]\n Omit project-name to un-group the namespace."); process.exit(1); }
1588
+ if (doCreate && !project) { console.error("--create needs a project name."); process.exit(1); }
1589
+ // Assignment RESOLVES an existing project and never creates one. The
1590
+ // server used to create a project for any unrecognised name, which turned
1591
+ // every typo into a new invisible project; --create makes creation an
1592
+ // explicit act instead of a side effect of misspelling.
1593
+ if (doCreate) {
1594
+ const { status: cStatus, json: cJson } = await accountCall("POST", "/api/projects", { name: project });
1595
+ if (cStatus === "201") console.log(` ${M.dim}created project "${project}"${M.rst}`);
1596
+ else if (cStatus === "409") console.log(` ${M.dim}project "${project}" already exists — assigning to it${M.rst}`);
1597
+ else { console.error(`could not create project (HTTP ${cStatus}): ${cJson?.error || ""}`); process.exit(1); }
1598
+ }
1536
1599
  const { status, json } = await accountCall("POST", "/api/namespace/set-project", { namespace_id: ns, project: project || "" });
1537
- if (status !== "200") { console.error(`set-project failed (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
1600
+ if (status === "404") {
1601
+ console.error(`No project named "${project}".`);
1602
+ console.error(` Create it first: patchcord project create ${project}`);
1603
+ console.error(` Or in one step: patchcord namespace set-project ${ns} ${project} --create`);
1604
+ process.exit(1);
1605
+ }
1606
+ if (status !== "200") { console.error(`set-project failed (HTTP ${status}): ${json?.error || ""}${json?.retryable ? " (transient — retry)" : ""}`); process.exit(1); }
1538
1607
  if (json.status === "cleared") {
1539
- console.log(`✓ ${ns} un-grouped — no project label`);
1608
+ console.log(`✓ ${ns} un-grouped — belongs to no project, and its lead has no cross-team peers`);
1540
1609
  } else {
1541
- console.log(`✓ ${ns} assigned to project "${json.project}" (label only — does not affect cross-namespace messaging)`);
1610
+ console.log(`✓ ${ns} assigned to project "${json.project}"`);
1611
+ console.log(` ${M.dim}its LEAD can now message the leads of other Teams in this project;${M.rst}`);
1612
+ console.log(` ${M.dim}other agents still need an approved link (request_namespace_link)${M.rst}`);
1542
1613
  }
1543
1614
  process.exit(0);
1544
1615
  }
1545
- console.error("Usage: patchcord namespace set-project <namespace> [project-name]");
1616
+ console.error("Usage: patchcord namespace set-project <namespace> [project-name] [--create]");
1617
+ process.exit(1);
1618
+ }
1619
+
1620
+ // ── projects: the grouping entity above namespaces ──────────────────
1621
+ // Exists because the server deliberately does NOT create a project as a side
1622
+ // effect of assignment. Without a create/list path here, the only way to make
1623
+ // one was a raw API call — and without a LIST path, a mistyped project was
1624
+ // invisible, which is the exact defect that got an earlier version of this
1625
+ // feature abandoned.
1626
+ if (cmd === "project" || cmd === "projects") {
1627
+ const sub = process.argv[3] || "list";
1628
+ const fail = (status, json, what) => {
1629
+ const retry = json?.retryable ? " (transient — retry shortly, your login is fine)" : "";
1630
+ console.error(`${what} failed (HTTP ${status}): ${json?.error || ""}${retry}`);
1631
+ process.exit(1);
1632
+ };
1633
+
1634
+ if (sub === "list" || sub === "ls") {
1635
+ const { status, json } = await accountCall("GET", "/api/projects");
1636
+ if (status !== "200") fail(status, json, "project list");
1637
+ const projects = json?.projects || [];
1638
+ if (!projects.length) {
1639
+ console.log("No projects yet.");
1640
+ console.log(` ${M.dim}patchcord project create <name>${M.rst}`);
1641
+ process.exit(0);
1642
+ }
1643
+ for (const p of projects) {
1644
+ const members = (p.namespaces || []);
1645
+ console.log(`${M.green}${p.name}${M.rst} ${M.dim}${p.id}${M.rst}`);
1646
+ if (members.length) for (const ns of members) console.log(` · ${ns}`);
1647
+ else console.log(` ${M.dim}(no Teams assigned)${M.rst}`);
1648
+ }
1649
+ process.exit(0);
1650
+ }
1651
+
1652
+ if (sub === "create" || sub === "add") {
1653
+ const name = process.argv[4];
1654
+ if (!name) { console.error("Usage: patchcord project create <name>"); process.exit(1); }
1655
+ const { status, json } = await accountCall("POST", "/api/projects", { name });
1656
+ if (status === "409") { console.error(`A project named "${name}" already exists.`); process.exit(1); }
1657
+ if (status !== "201") fail(status, json, "project create");
1658
+ console.log(`✓ created project "${json.name}" ${M.dim}${json.id}${M.rst}`);
1659
+ console.log(` ${M.dim}patchcord namespace set-project <namespace> ${json.name}${M.rst}`);
1660
+ process.exit(0);
1661
+ }
1662
+
1663
+ if (sub === "rename" || sub === "mv") {
1664
+ const id = process.argv[4];
1665
+ const name = process.argv[5];
1666
+ if (!id || !name) { console.error("Usage: patchcord project rename <project-id> <new-name>\n Get the id from `patchcord project list`."); process.exit(1); }
1667
+ const { status, json } = await accountCall("PATCH", `/api/projects/${encodeURIComponent(id)}`, { name });
1668
+ if (status === "404") { console.error(`No project with id ${id}.`); process.exit(1); }
1669
+ if (status === "409") { console.error(`A project named "${name}" already exists.`); process.exit(1); }
1670
+ if (status !== "200") fail(status, json, "project rename");
1671
+ console.log(`✓ renamed to "${json.name}" ${M.dim}(membership and reach unchanged — this is only the label)${M.rst}`);
1672
+ process.exit(0);
1673
+ }
1674
+
1675
+ if (sub === "rm" || sub === "delete") {
1676
+ const id = process.argv[4];
1677
+ if (!id) { console.error("Usage: patchcord project rm <project-id>\n Get the id from `patchcord project list`."); process.exit(1); }
1678
+ const { status, json } = await accountCall("DELETE", `/api/projects/${encodeURIComponent(id)}`);
1679
+ if (status === "404") { console.error(`No project with id ${id}.`); process.exit(1); }
1680
+ if (status !== "200") fail(status, json, "project rm");
1681
+ const ungrouped = json?.ungrouped_namespaces || [];
1682
+ console.log(`✓ deleted project ${M.dim}${id}${M.rst}`);
1683
+ if (ungrouped.length) {
1684
+ // Deleting a project withdraws the lead-to-lead channel between exactly
1685
+ // these Teams. Fail-closed — reach is only ever removed here, never
1686
+ // widened — but it must be stated rather than discovered.
1687
+ console.log(` ${ungrouped.length} Team(s) un-grouped; their leads no longer reach each other:`);
1688
+ for (const ns of ungrouped) console.log(` · ${ns}`);
1689
+ } else {
1690
+ console.log(` ${M.dim}no Teams were assigned to it${M.rst}`);
1691
+ }
1692
+ process.exit(0);
1693
+ }
1694
+
1695
+ console.error("Usage: patchcord project <list|create|rename|rm>");
1546
1696
  process.exit(1);
1547
1697
  }
1548
1698
 
1549
1699
  if (cmd === "pull") {
1550
1700
  // Inverse of provision: place an EXISTING agent identity into a folder.
1551
- // Server supersedes the prior active token for this identity (exactly ONE
1552
- // live credential) and PRESERVES is_lead from that active token request
1553
- // body --lead / role cannot escalate a worker. CLI --lead is typed
1554
- // symmetry only; server is source of truth.
1701
+ //
1702
+ // REUSE FIRST, MINT ONLY AS A LAST RESORT. This command is named `pull` and
1703
+ // its whole purpose is "use the identity that already exists", but it used
1704
+ // to go straight to POST /api/provision — which MINTS a new token and, via
1705
+ // insert_bearer_token's supersede, deactivates every prior token for that
1706
+ // identity. So a `pull` silently killed the credential held in memory by any
1707
+ // LIVE session of the same agent.
1708
+ //
1709
+ // That is not a theoretical concern: `mux new <seat>` calls this on every
1710
+ // relaunch whenever the identity already exists, so relaunching one seat
1711
+ // revoked the running session's token. Nobody typed `patchcord pull`, no
1712
+ // deploy ran, and no revoke was requested — yet agents lost patchcord
1713
+ // mid-session with no visible cause. Worse, the documented recovery for a
1714
+ // dead token was to re-run pull/provision, which armed the next one.
1715
+ //
1716
+ // The server cannot hand back an existing token: only the HASH is stored
1717
+ // (plaintext was deliberately dropped). So reuse has to happen HERE, from a
1718
+ // config already on this machine, and be proven live before it is trusted.
1555
1719
  const arg = process.argv[3];
1556
- if (!arg || arg.startsWith("-")) { console.error("Usage: patchcord pull <agent> --namespace ns --tool X [--dir sub/] [--lead]"); process.exit(1); }
1720
+ if (!arg || arg.startsWith("-")) { console.error("Usage: patchcord pull <agent> --namespace ns --tool X [--dir sub/] [--lead] [--force-remint]"); process.exit(1); }
1557
1721
  const tool = requireToolFlag("patchcord pull <agent> --tool X --namespace ns [--dir sub/] [--lead]");
1558
1722
  const ns = flagVal("namespace");
1559
1723
  const subdir = flagVal("dir", arg);
1560
1724
  const lead = process.argv.includes("--lead"); // typed symmetry; server ignores escalate-on-pull
1725
+ const forceRemint = process.argv.includes("--force-remint");
1561
1726
  if (!ns) { console.error("--namespace <project-namespace> required"); process.exit(1); }
1727
+
1728
+ const dir = join(process.cwd(), subdir);
1729
+ const hostname = run("hostname -s") || run("hostname") || "unknown";
1730
+
1731
+ // ── Phase 1: is a healthy credential for this exact identity already here?
1732
+ // Verified against the server rather than assumed from the file: a token on
1733
+ // disk may itself already be superseded, and writing a dead credential
1734
+ // forward would be worse than reminting. whoami is a read — it cannot
1735
+ // supersede anything.
1736
+ if (!forceRemint) {
1737
+ let reusable = null;
1738
+ try {
1739
+ const { listProjectBearers } = await import(
1740
+ new URL("../scripts/lib/resolve-project-bearer.mjs", import.meta.url).href
1741
+ );
1742
+ const seen = new Set();
1743
+ // The target folder first (the common case: relaunching a seat whose
1744
+ // config is already correct), then upward through the project.
1745
+ for (const cand of [...listProjectBearers(dir), ...listProjectBearers(process.cwd())]) {
1746
+ if (!cand?.token || seen.has(cand.token)) continue;
1747
+ seen.add(cand.token);
1748
+ const baseUrl = String(cand.url || "").replace(/\/mcp(\/bearer)?$/, "") || DEFAULT_API;
1749
+ const probe = await _httpJSON("GET", `${baseUrl}/api/agent/whoami`, cand.token);
1750
+ if (probe.status !== "200") continue; // dead or superseded — skip
1751
+ const who = probe.json || {};
1752
+ const gotNs = who.namespace_id || who.namespace || (who.self && who.self.namespace_id);
1753
+ const gotAgent = who.agent_id || who.agent || (who.self && who.self.agent_id);
1754
+ // Must be the SAME identity. A live token for a DIFFERENT agent is not
1755
+ // a substitute — writing it here would hand this folder another
1756
+ // agent's identity, which is how seats end up impersonating peers.
1757
+ if (String(gotNs) === ns && String(gotAgent) === arg) {
1758
+ reusable = { token: cand.token, base: baseUrl };
1759
+ break;
1760
+ }
1761
+ }
1762
+ } catch (e) {
1763
+ // Reuse is an optimisation over the old behaviour; if it cannot run we
1764
+ // fall through and mint, exactly as before.
1765
+ console.error(` ${M.dim}(could not check for a reusable credential: ${e?.message || e})${M.rst}`);
1766
+ }
1767
+ if (reusable) {
1768
+ writeWorkerConfig(tool, dir, reusable.base, reusable.token, hostname);
1769
+ try {
1770
+ const tj = join(process.cwd(), ".patchcord", "team.json");
1771
+ if (existsSync(tj)) {
1772
+ const man = JSON.parse(readFileSync(tj, "utf-8"));
1773
+ man.agents = (man.agents || []).filter((a) => a.agent !== arg);
1774
+ man.agents.push({ agent: arg, tool, dir: subdir, namespace: ns });
1775
+ writeFileSync(tj, JSON.stringify(man, null, 2) + "\n");
1776
+ }
1777
+ } catch {}
1778
+ console.log(`✓ pulled ${M.green}${ns}:${arg}${M.rst} [${tool}] → ${dir}`);
1779
+ console.log(` ${M.dim}reused the existing live credential — nothing was superseded,${M.rst}`);
1780
+ console.log(` ${M.dim}so any running session for this agent keeps working.${M.rst}`);
1781
+ process.exit(0);
1782
+ }
1783
+ }
1784
+
1785
+ // ── Phase 2: no healthy local credential. Minting is the only option left,
1786
+ // and it WILL supersede. Say so, loudly, before doing it — this is the step
1787
+ // that kills live sessions and it must never again be silent.
1788
+ console.error(` ${M.dim}no healthy local credential for ${ns}:${arg} — minting a new one.${M.rst}`);
1789
+ console.error(` ${M.dim}This SUPERSEDES any token this identity is currently using:${M.rst}`);
1790
+ console.error(` ${M.dim}a live session holding the old one will start failing and must reconnect its MCP client.${M.rst}`);
1562
1791
  const pullBody = { namespace_id: ns, agent_id: arg, tool, require_existing: true, label: `pull:${tool}` };
1563
1792
  if (lead) pullBody.is_lead = true; // server will still preserve, not escalate
1564
1793
  const { status, json, m } = await accountCall("POST", "/api/provision", pullBody);
1565
1794
  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); }
1566
1795
  if (status !== "200" || !json?.token) { console.error(`pull failed (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
1567
1796
  const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp(\/bearer)?$/, "");
1568
- const dir = join(process.cwd(), subdir);
1569
- const hostname = run("hostname -s") || run("hostname") || "unknown";
1797
+ if (typeof json.superseded === "number" && json.superseded > 0) {
1798
+ console.error(` ${M.dim}superseded ${json.superseded} previously-active token(s) for ${ns}:${arg}.${M.rst}`);
1799
+ }
1570
1800
  writeWorkerConfig(tool, dir, base, json.token, hostname);
1571
1801
  // Record in the local team manifest so `team launch` / `team status` see it.
1572
1802
  try {
@@ -1595,21 +1825,17 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1595
1825
  const project = flagVal("project", "");
1596
1826
  const hostname = run("hostname -s") || run("hostname") || "unknown";
1597
1827
  // is_lead: true always — the orchestrator IS the one identity per namespace
1598
- // meant to coordinate across teams. Harmless by itself: a lead only reaches
1599
- // another namespace's lead after an approved namespace_links pair (agent-to-
1600
- // agent via request_namespace_link/respond_namespace_link — fails closed
1601
- // until both leads explicitly consent).
1828
+ // meant to coordinate across teams. Not harmless by itself any more: a lead
1829
+ // reaches another namespace's lead EITHER after an approved namespace_links
1830
+ // pair (agent-to-agent via request_namespace_link/respond_namespace_link) OR
1831
+ // when both namespaces sit in the same PROJECT, which needs no handshake.
1832
+ // So --project below is a reachability decision, not just a label.
1602
1833
  const { status, json, m } = await accountCall("POST", "/api/provision", { namespace_id: ns, agent_id: "orchestrator", tool, role: "orchestrator", label: "orchestrator:self", is_lead: true });
1603
1834
  if (status !== "200" || !json?.token) { console.error(`could not set up orchestrator (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
1604
1835
  const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp(\/bearer)?$/, "");
1605
1836
  writeWorkerConfig(tool, root, base, json.token, hostname);
1606
1837
  if (project) {
1607
- const { status: pStatus, json: pJson } = await accountCall("POST", "/api/namespace/set-project", { namespace_id: ns, project });
1608
- if (pStatus !== "200") {
1609
- console.error(` ${M.dim}warning: orchestrator set up OK, but --project failed (HTTP ${pStatus}): ${pJson?.error || ""}${M.rst}`);
1610
- } else {
1611
- console.log(` ${M.dim}namespace ${ns} assigned to project "${project}"${M.rst}`);
1612
- }
1838
+ await assignProject(ns, project, { warnOnly: true, label: "orchestrator set up OK" });
1613
1839
  }
1614
1840
  mkdirSync(join(root, ".patchcord"), { recursive: true });
1615
1841
  const tj = join(root, ".patchcord", "team.json");
@@ -4034,5 +4260,5 @@ if (cmd === "skill") {
4034
4260
  process.exit(0);
4035
4261
  }
4036
4262
 
4037
- console.error(`Unknown command: ${cmd}. Available: install, whoami, agents, upload, subscribe, update, login, provision, pull, orchestrator, team, schedule, --rename, --token, --agent-type, --version, --help`);
4263
+ console.error(`Unknown command: ${cmd}. Available: install, whoami, agents, upload, subscribe, update, login, provision, pull, orchestrator, team, project, namespace, schedule, --rename, --token, --agent-type, --version, --help`);
4038
4264
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.34",
3
+ "version": "0.6.36",
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"
@@ -14,6 +14,7 @@ else
14
14
  pc_inbox_reason() { printf '%s patchcord message(s) waiting. Call inbox() and reply to all immediately.' "$1"; }
15
15
  pc_streak_ok() { return 0; }
16
16
  pc_state_path() { printf ''; }
17
+ pc_streak_n() { printf '1'; }
17
18
  fi
18
19
 
19
20
  INPUT=$(cat)
@@ -161,5 +162,9 @@ if [ -n "$STREAK_STATE" ] \
161
162
  fi
162
163
 
163
164
  touch "$NOTIFY_LOCK"
164
- REASON=$(pc_inbox_reason "$COUNT" "$NAMESPACE" "$AGENT_ID" "inbox()" "restart this Claude Code session")
165
+ # Read the streak AFTER pc_streak_ok has incremented it, so the text can
166
+ # escalate on evidence: quiet on the first nudge, and only explaining the
167
+ # stale-token condition once a repeat proves inbox() did not clear it.
168
+ NUDGE_STREAK=$(pc_streak_n "$STREAK_STATE" 2>/dev/null || echo 1)
169
+ REASON=$(pc_inbox_reason "$COUNT" "$NAMESPACE" "$AGENT_ID" "inbox()" "restart this Claude Code session" "$NUDGE_STREAK")
165
170
  jq -n --arg reason "$REASON" '{"decision": "block", "reason": $reason}'
@@ -20,6 +20,7 @@ else
20
20
  pc_inbox_reason() { printf '%s patchcord message(s) waiting. Call inbox() and reply to all.' "$1"; }
21
21
  pc_streak_ok() { return 0; }
22
22
  pc_state_path() { printf ''; }
23
+ pc_streak_n() { printf '1'; }
23
24
  fi
24
25
 
25
26
  INPUT=$(cat)
@@ -104,5 +105,8 @@ if [ -n "$STREAK_STATE" ] \
104
105
  fi
105
106
 
106
107
  touch "$NOTIFY_LOCK"
107
- REASON=$(pc_inbox_reason "$COUNT" "$NAMESPACE" "$AGENT_ID" "inbox()" "restart this Codex session")
108
+ # See check-inbox.sh: quiet on the first nudge, explain only once a repeat has
109
+ # proved inbox() did not clear it.
110
+ NUDGE_STREAK=$(pc_streak_n "$STREAK_STATE" 2>/dev/null || echo 1)
111
+ REASON=$(pc_inbox_reason "$COUNT" "$NAMESPACE" "$AGENT_ID" "inbox()" "restart this Codex session" "$NUDGE_STREAK")
108
112
  jq -n --arg reason "$REASON" '{"decision":"block","reason":$reason}'
@@ -20,15 +20,42 @@
20
20
  # something the agent can read and act on, and gives up after a few identical
21
21
  # blocks instead of looping forever.
22
22
 
23
- # pc_inbox_reason <count> <namespace> <agent> <inbox_call> <restart_hint>
23
+ # pc_inbox_reason <count> <namespace> <agent> <inbox_call> <restart_hint> [streak]
24
24
  #
25
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.
26
+ # agent can recognise the mismatch.
27
+ #
28
+ # THE STALE-IDENTITY EXPLANATION IS ONLY EMITTED ONCE THE SYMPTOM HAS ACTUALLY
29
+ # OCCURRED. It used to be appended to EVERY nudge, fleet-wide, which was wrong
30
+ # for three reasons:
31
+ #
32
+ # 1. It is ~7 lines of failure analysis attached to "you have one message" —
33
+ # the overwhelmingly common case, where nothing is wrong at all.
34
+ # 2. It made every agent read a paragraph about its own credential possibly
35
+ # being dead, on every single nudge. That is not neutral: an agent told
36
+ # often enough that its identity may be broken starts diagnosing that
37
+ # instead of doing the work. One harness did exactly this — it hit a normal
38
+ # empty inbox, concluded "the session token appears stale or replaced",
39
+ # refused to call inbox() again for the rest of the session, and asked the
40
+ # human to restart. Nothing was wrong with its token. The nudge taught it
41
+ # that conclusion.
42
+ # 3. A warning that fires on every occasion carries no information on any
43
+ # occasion. It gets skimmed, and then it is not there when it matters.
44
+ #
45
+ # `streak` is the consecutive-identical-block count from pc_streak_ok. A streak
46
+ # of 1 means "first time we are telling you" — short text, no theory. A streak of
47
+ # 2+ means the agent has already been nudged for this exact identity and count
48
+ # and it did NOT clear, which is the actual evidence of the stale-token
49
+ # condition. That is when the explanation is worth its length.
50
+ #
51
+ # Omitted/unknown streak is treated as the first block, i.e. short. Callers that
52
+ # do not pass it keep the quiet wording, which is the safe default.
28
53
  pc_inbox_reason() {
29
54
  local count="$1" ns="$2" agent="$3" call="${4:-inbox()}" hint="${5:-restart this session}"
55
+ local streak="${6:-1}"
30
56
  local noun="message(s)"
31
57
  [ "$count" = "1" ] && noun="message"
58
+ case "$streak" in ''|*[!0-9]*) streak=1 ;; esac
32
59
 
33
60
  if [ -z "$ns" ] || [ -z "$agent" ]; then
34
61
  printf '%s patchcord %s waiting. Call %s and reply to all immediately.' \
@@ -36,13 +63,34 @@ pc_inbox_reason() {
36
63
  return
37
64
  fi
38
65
 
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.' \
66
+ if [ "$streak" -lt 2 ]; then
67
+ printf '%s patchcord %s waiting for %s@%s. Call %s and reply to all immediately.' \
68
+ "$count" "$noun" "$agent" "$ns" "$call"
69
+ return
70
+ fi
71
+
72
+ printf 'STILL %s patchcord %s waiting for %s@%s after a previous nudge — %s did not clear it. Call %s once more. If it 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. In that case do NOT keep calling %s — say so and ask the user to %s so it reloads the config.' \
40
73
  "$count" "$noun" "$agent" "$ns" \
41
74
  "$call" \
42
- "$call" "$agent" "$ns" \
75
+ "$call" \
76
+ "$agent" "$ns" \
43
77
  "$call" "$hint"
44
78
  }
45
79
 
80
+ # pc_streak_n <state_file>
81
+ #
82
+ # The current consecutive-identical-block count, or 0. Read AFTER pc_streak_ok
83
+ # has run, so it reflects this block. Exists so the nudge text can escalate on
84
+ # evidence instead of warning unconditionally.
85
+ pc_streak_n() {
86
+ local prev n
87
+ prev=$(cat "$1" 2>/dev/null || true)
88
+ [ -n "$prev" ] || { printf '0'; return; }
89
+ n="${prev##*|}"
90
+ case "$n" in ''|*[!0-9]*) n=0 ;; esac
91
+ printf '%s' "$n"
92
+ }
93
+
46
94
  # pc_streak_ok <state_file> <key> <max>
47
95
  #
48
96
  # Consecutive-identical-block breaker. `key` should encode identity + count, so