patchcord 0.6.35 → 0.6.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/patchcord.mjs
CHANGED
|
@@ -1698,25 +1698,139 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
|
|
|
1698
1698
|
|
|
1699
1699
|
if (cmd === "pull") {
|
|
1700
1700
|
// Inverse of provision: place an EXISTING agent identity into a folder.
|
|
1701
|
-
//
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
//
|
|
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.
|
|
1705
1719
|
const arg = process.argv[3];
|
|
1706
|
-
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); }
|
|
1707
1721
|
const tool = requireToolFlag("patchcord pull <agent> --tool X --namespace ns [--dir sub/] [--lead]");
|
|
1708
1722
|
const ns = flagVal("namespace");
|
|
1709
1723
|
const subdir = flagVal("dir", arg);
|
|
1710
1724
|
const lead = process.argv.includes("--lead"); // typed symmetry; server ignores escalate-on-pull
|
|
1725
|
+
const forceRemint = process.argv.includes("--force-remint");
|
|
1711
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
|
+
let undetermined = null; // a candidate we could NOT judge — see below
|
|
1739
|
+
try {
|
|
1740
|
+
const { listProjectBearers } = await import(
|
|
1741
|
+
new URL("../scripts/lib/resolve-project-bearer.mjs", import.meta.url).href
|
|
1742
|
+
);
|
|
1743
|
+
const seen = new Set();
|
|
1744
|
+
// The target folder first (the common case: relaunching a seat whose
|
|
1745
|
+
// config is already correct), then upward through the project.
|
|
1746
|
+
for (const cand of [...listProjectBearers(dir), ...listProjectBearers(process.cwd())]) {
|
|
1747
|
+
if (!cand?.token || seen.has(cand.token)) continue;
|
|
1748
|
+
seen.add(cand.token);
|
|
1749
|
+
const baseUrl = String(cand.url || "").replace(/\/mcp(\/bearer)?$/, "") || DEFAULT_API;
|
|
1750
|
+
const probe = await _httpJSON("GET", `${baseUrl}/api/agent/whoami`, cand.token);
|
|
1751
|
+
const code = String(probe.status ?? "");
|
|
1752
|
+
|
|
1753
|
+
// THREE outcomes, not two. Collapsing them is the whole bug class this
|
|
1754
|
+
// command exists to stop causing:
|
|
1755
|
+
//
|
|
1756
|
+
// 401/403 the credential is genuinely dead (revoked in the web
|
|
1757
|
+
// console, or superseded). Skip it; minting is CORRECT.
|
|
1758
|
+
// 200 alive — check whose it is below.
|
|
1759
|
+
// anything else (000 from a failed connection, null when curl itself
|
|
1760
|
+
// died, any 5xx) we DO NOT KNOW. Treating "could not ask"
|
|
1761
|
+
// as "dead" would skip a HEALTHY token and mint, which
|
|
1762
|
+
// destroys the live session this command exists to protect
|
|
1763
|
+
// — and it would do that exactly when the backend is
|
|
1764
|
+
// already unwell. Absent, dead and unavailable are three
|
|
1765
|
+
// different answers.
|
|
1766
|
+
if (code === "401" || code === "403") continue;
|
|
1767
|
+
if (code !== "200") {
|
|
1768
|
+
undetermined = { token: cand.token, base: baseUrl, code: code || "no response" };
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const who = probe.json || {};
|
|
1773
|
+
const gotNs = who.namespace_id || who.namespace || (who.self && who.self.namespace_id);
|
|
1774
|
+
const gotAgent = who.agent_id || who.agent || (who.self && who.self.agent_id);
|
|
1775
|
+
// Must be the SAME identity. A live token for a DIFFERENT agent is not
|
|
1776
|
+
// a substitute — writing it here would hand this folder another
|
|
1777
|
+
// agent's identity, which is how seats end up impersonating peers.
|
|
1778
|
+
if (String(gotNs) === ns && String(gotAgent) === arg) {
|
|
1779
|
+
reusable = { token: cand.token, base: baseUrl };
|
|
1780
|
+
break;
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
} catch (e) {
|
|
1784
|
+
// A crash in the reuse scan is itself "undetermined", not "dead". Same
|
|
1785
|
+
// reasoning: never let a local failure authorise a destructive remint.
|
|
1786
|
+
undetermined = undetermined || { code: `scan failed: ${e?.message || e}` };
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// Could not determine, and found nothing provably alive: STOP. Do not
|
|
1790
|
+
// mint. Minting is the irreversible half of this command — it kills a
|
|
1791
|
+
// credential a running session may be holding — and an unreachable server
|
|
1792
|
+
// is not evidence that anything needs replacing. Retrying is free; a
|
|
1793
|
+
// revoked live session is not.
|
|
1794
|
+
if (!reusable && undetermined) {
|
|
1795
|
+
console.error(`✗ cannot verify the existing credential for ${M.green}${ns}:${arg}${M.rst} (${undetermined.code}).`);
|
|
1796
|
+
console.error(` Refusing to mint a replacement: that would revoke the token any`);
|
|
1797
|
+
console.error(` running session for this agent is currently using.`);
|
|
1798
|
+
console.error(` ${M.dim}Retry when the server is reachable, or force it deliberately with --force-remint.${M.rst}`);
|
|
1799
|
+
process.exit(1);
|
|
1800
|
+
}
|
|
1801
|
+
if (reusable) {
|
|
1802
|
+
writeWorkerConfig(tool, dir, reusable.base, reusable.token, hostname);
|
|
1803
|
+
try {
|
|
1804
|
+
const tj = join(process.cwd(), ".patchcord", "team.json");
|
|
1805
|
+
if (existsSync(tj)) {
|
|
1806
|
+
const man = JSON.parse(readFileSync(tj, "utf-8"));
|
|
1807
|
+
man.agents = (man.agents || []).filter((a) => a.agent !== arg);
|
|
1808
|
+
man.agents.push({ agent: arg, tool, dir: subdir, namespace: ns });
|
|
1809
|
+
writeFileSync(tj, JSON.stringify(man, null, 2) + "\n");
|
|
1810
|
+
}
|
|
1811
|
+
} catch {}
|
|
1812
|
+
console.log(`✓ pulled ${M.green}${ns}:${arg}${M.rst} [${tool}] → ${dir}`);
|
|
1813
|
+
console.log(` ${M.dim}reused the existing live credential — nothing was superseded,${M.rst}`);
|
|
1814
|
+
console.log(` ${M.dim}so any running session for this agent keeps working.${M.rst}`);
|
|
1815
|
+
process.exit(0);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// ── Phase 2: no healthy local credential. Minting is the only option left,
|
|
1820
|
+
// and it WILL supersede. Say so, loudly, before doing it — this is the step
|
|
1821
|
+
// that kills live sessions and it must never again be silent.
|
|
1822
|
+
console.error(` ${M.dim}no healthy local credential for ${ns}:${arg} — minting a new one.${M.rst}`);
|
|
1823
|
+
console.error(` ${M.dim}This SUPERSEDES any token this identity is currently using:${M.rst}`);
|
|
1824
|
+
console.error(` ${M.dim}a live session holding the old one will start failing and must reconnect its MCP client.${M.rst}`);
|
|
1712
1825
|
const pullBody = { namespace_id: ns, agent_id: arg, tool, require_existing: true, label: `pull:${tool}` };
|
|
1713
1826
|
if (lead) pullBody.is_lead = true; // server will still preserve, not escalate
|
|
1714
1827
|
const { status, json, m } = await accountCall("POST", "/api/provision", pullBody);
|
|
1715
1828
|
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); }
|
|
1716
1829
|
if (status !== "200" || !json?.token) { console.error(`pull failed (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
|
|
1717
1830
|
const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp(\/bearer)?$/, "");
|
|
1718
|
-
|
|
1719
|
-
|
|
1831
|
+
if (typeof json.superseded === "number" && json.superseded > 0) {
|
|
1832
|
+
console.error(` ${M.dim}superseded ${json.superseded} previously-active token(s) for ${ns}:${arg}.${M.rst}`);
|
|
1833
|
+
}
|
|
1720
1834
|
writeWorkerConfig(tool, dir, base, json.token, hostname);
|
|
1721
1835
|
// Record in the local team manifest so `team launch` / `team status` see it.
|
|
1722
1836
|
try {
|
package/package.json
CHANGED
package/scripts/check-inbox.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
27
|
-
#
|
|
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
|
-
|
|
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"
|
|
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
|