patchcord 0.6.36 → 0.6.38

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.36",
4
+ "version": "0.6.38",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
package/bin/patchcord.mjs CHANGED
@@ -1735,6 +1735,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1735
1735
  // supersede anything.
1736
1736
  if (!forceRemint) {
1737
1737
  let reusable = null;
1738
+ let undetermined = null; // a candidate we could NOT judge — see below
1738
1739
  try {
1739
1740
  const { listProjectBearers } = await import(
1740
1741
  new URL("../scripts/lib/resolve-project-bearer.mjs", import.meta.url).href
@@ -1747,7 +1748,27 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1747
1748
  seen.add(cand.token);
1748
1749
  const baseUrl = String(cand.url || "").replace(/\/mcp(\/bearer)?$/, "") || DEFAULT_API;
1749
1750
  const probe = await _httpJSON("GET", `${baseUrl}/api/agent/whoami`, cand.token);
1750
- if (probe.status !== "200") continue; // dead or superseded — skip
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
+
1751
1772
  const who = probe.json || {};
1752
1773
  const gotNs = who.namespace_id || who.namespace || (who.self && who.self.namespace_id);
1753
1774
  const gotAgent = who.agent_id || who.agent || (who.self && who.self.agent_id);
@@ -1760,9 +1781,22 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1760
1781
  }
1761
1782
  }
1762
1783
  } 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}`);
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);
1766
1800
  }
1767
1801
  if (reusable) {
1768
1802
  writeWorkerConfig(tool, dir, reusable.base, reusable.token, hostname);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.36",
3
+ "version": "0.6.38",
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"
@@ -75,7 +75,22 @@ fi
75
75
  # Check inbox — one lightweight HTTP call
76
76
  MACHINE_NAME=$(hostname -s 2>/dev/null || echo "unknown")
77
77
  INSTALL_PATH=$(dirname "$MCP_JSON")
78
- HTTP_CODE=$(curl -s -o /tmp/patchcord_inbox.json -w "%{http_code}" --max-time 5 \
78
+
79
+ # PER-INVOCATION response file. This was a FIXED path (/tmp/patchcord_inbox.json)
80
+ # shared by every agent on the machine, and a host running several seats stops
81
+ # them at overlapping times: one hook's curl truncates and rewrites the file
82
+ # another hook is reading, and either one's `rm -f` deletes it underneath the
83
+ # other. The reader then hands a half-written body to jq, which is exactly the
84
+ # "Invalid numeric literal at line 1, column N" this produced on the Mac mini.
85
+ # A shared mutable temp path between concurrent agents is not a race that
86
+ # happens rarely; it is one that happens whenever the machine is busy.
87
+ RESP_FILE=$(mktemp "${TMPDIR:-/tmp}/patchcord_inbox.XXXXXX" 2>/dev/null || echo "")
88
+ [ -z "$RESP_FILE" ] && exit 0
89
+ # Remove it on ANY exit path, including the early returns below, so a crash
90
+ # cannot leave per-invocation files accumulating in /tmp.
91
+ trap 'rm -f "$RESP_FILE"' EXIT
92
+
93
+ HTTP_CODE=$(curl -s -o "$RESP_FILE" -w "%{http_code}" --max-time 5 \
79
94
  -H "Authorization: Bearer ${TOKEN}" \
80
95
  -H "x-patchcord-machine: ${MACHINE_NAME}" \
81
96
  -H "x-patchcord-install-path: ${INSTALL_PATH}" \
@@ -86,18 +101,25 @@ if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "403" ]; then
86
101
  "decision": "block",
87
102
  "reason": "PATCHCORD AUTH FAILED: token rejected by server (HTTP '"$HTTP_CODE"'). Check your token in .mcp.json — it may be wrong, expired, or not yet registered on the server."
88
103
  }'
89
- rm -f /tmp/patchcord_inbox.json
90
104
  exit 0
91
105
  fi
92
106
 
93
107
  if [ "$HTTP_CODE" = "000" ]; then
94
108
  # Server unreachable — skip silently
95
- rm -f /tmp/patchcord_inbox.json
96
109
  exit 0
97
110
  fi
98
111
 
99
- RESPONSE=$(cat /tmp/patchcord_inbox.json 2>/dev/null || echo '{"count":0}')
100
- rm -f /tmp/patchcord_inbox.json
112
+ RESPONSE=$(cat "$RESP_FILE" 2>/dev/null || echo "")
113
+
114
+ # "COULD NOT TELL" IS NOT "NOTHING PENDING". Below, an unparseable body used to
115
+ # fall through jq's `// 0` default to COUNT=0, which takes the same exit path as
116
+ # a genuinely empty inbox AND clears the streak state. So a corrupted response
117
+ # told the agent it had no messages, silently, with no way to tell the two apart
118
+ # — the same absence-reads-as-a-value shape that cost this project a night.
119
+ # Refuse to answer instead: leave the streak state alone and say nothing.
120
+ if ! printf '%s' "$RESPONSE" | jq -e 'type == "object"' >/dev/null 2>&1; then
121
+ exit 0
122
+ fi
101
123
 
102
124
  # ── Auto-apply custom skill from web console ──────────────────
103
125
  # Writes to .claude/skills/patchcord-custom/SKILL.md — Claude Code
@@ -142,7 +164,14 @@ if [ "$COUNT" -eq 0 ]; then
142
164
  exit 0
143
165
  fi
144
166
 
145
- NOTIFY_LOCK="/tmp/patchcord_notify_lock"
167
+ # PER-IDENTITY lock. This was a single global path, so on a host running several
168
+ # seats one agent's notification suppressed EVERY other agent's for 5s — an
169
+ # agent with real pending messages could be silenced by an unrelated seat that
170
+ # happened to stop first. The dedupe is meant to stop ONE identity being told
171
+ # twice by the Stop and Notification hooks, never to stop a second identity
172
+ # being told at all.
173
+ NOTIFY_LOCK=$(pc_state_path "patchcord_notify_lock" "$NAMESPACE" "$AGENT_ID" 2>/dev/null || echo "")
174
+ [ -z "$NOTIFY_LOCK" ] && NOTIFY_LOCK="${TMPDIR:-/tmp}/patchcord_notify_lock_$(printf '%s' "${NAMESPACE}/${AGENT_ID}" | tr -c 'a-zA-Z0-9' '_')"
146
175
  LOCK_AGE=5
147
176
  if [ -f "$NOTIFY_LOCK" ]; then
148
177
  LOCK_MTIME=$(stat -c %Y "$NOTIFY_LOCK" 2>/dev/null || stat -f %m "$NOTIFY_LOCK" 2>/dev/null || echo "0")