patchcord 0.6.27 → 0.6.29

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.27",
4
+ "version": "0.6.29",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
package/bin/patchcord.mjs CHANGED
@@ -16,7 +16,9 @@ const cmd = process.argv[2];
16
16
 
17
17
  /** Write a file then chmod 0600 (token-bearing configs must not be group/world readable). */
18
18
  function writeSecureFile(path, contents) {
19
- writeFileSync(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 });
20
22
  try { chmodSync(path, 0o600); } catch {}
21
23
  return path;
22
24
  }
@@ -184,6 +186,19 @@ team (run from the project root):
184
186
  provisioned agents (server view)
185
187
  patchcord team blueprint current --namespace <ns> [--json]
186
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)
192
+ patchcord team deployment request --namespace <ns> [--revision <uid>] [--json]
193
+ enqueue an apply of a saved revision
194
+ (exit 3 = one is already in flight)
195
+ patchcord team deployment pending [--json] jobs waiting for this host to claim
196
+ patchcord team deployment claim --job <uid> [--installation <name>] [--json]
197
+ take ownership (exit 3 = lost the race)
198
+ patchcord team deployment complete --job <uid> --status <healthy|degraded|cancelled>
199
+ [--result <json> | --result-file <path>] [--json]
200
+ patchcord team deployment get --namespace <ns> (--job <uid> | --active) [--json]
201
+ one job, or the namespace's active job
187
202
  patchcord team status reconcile folder ↔ identity ↔ tmux ↔ token
188
203
  patchcord team launch launch each worker in mux
189
204
 
@@ -725,7 +740,10 @@ if (cmd === "whoami") {
725
740
  if (json.whoami) {
726
741
  console.log(`self: ${json.whoami}`);
727
742
  } else {
728
- console.log(`self: (empty propose with: patchcord whoami --propose "<text>")`);
743
+ // Empty self-description is a NORMAL state for a healthy identity — a
744
+ // fresh mux-v2 seat once fail-closed reading "(empty …)" as "not
745
+ // provisioned". Say explicitly that identity is fine.
746
+ console.log(`self-description: (not set — optional; your identity ${json.agent}@${json.namespace} is fully provisioned. Set one with: patchcord whoami --propose "<text>")`);
729
747
  }
730
748
  console.log();
731
749
  console.log(`tip: \`patchcord agents\` returns whoami for all peers.`);
@@ -1412,7 +1430,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1412
1430
  if (lead) provisionBody.is_lead = true;
1413
1431
  const { status, json, m } = await accountCall("POST", "/api/provision", provisionBody);
1414
1432
  if (status !== "200" || !json?.token) { console.error(`provision failed (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
1415
- const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp$/, "");
1433
+ const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp(\/bearer)?$/, "");
1416
1434
  const dir = join(process.cwd(), subdir);
1417
1435
  const hostname = run("hostname -s") || run("hostname") || "unknown";
1418
1436
  writeWorkerConfig(tool, dir, base, json.token, hostname);
@@ -1492,7 +1510,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1492
1510
  const { status, json, m } = await accountCall("POST", "/api/provision", pullBody);
1493
1511
  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); }
1494
1512
  if (status !== "200" || !json?.token) { console.error(`pull failed (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
1495
- const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp$/, "");
1513
+ const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp(\/bearer)?$/, "");
1496
1514
  const dir = join(process.cwd(), subdir);
1497
1515
  const hostname = run("hostname -s") || run("hostname") || "unknown";
1498
1516
  writeWorkerConfig(tool, dir, base, json.token, hostname);
@@ -1529,7 +1547,7 @@ if (cmd === "login" || cmd === "orchestrator" || cmd === "teamlead" || cmd === "
1529
1547
  // until both leads explicitly consent).
1530
1548
  const { status, json, m } = await accountCall("POST", "/api/provision", { namespace_id: ns, agent_id: "orchestrator", tool, role: "orchestrator", label: "orchestrator:self", is_lead: true });
1531
1549
  if (status !== "200" || !json?.token) { console.error(`could not set up orchestrator (HTTP ${status}): ${json?.error || ""}`); process.exit(1); }
1532
- const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp$/, "");
1550
+ const base = String(json.url || `${m.baseUrl}/mcp`).replace(/\/mcp(\/bearer)?$/, "");
1533
1551
  writeWorkerConfig(tool, root, base, json.token, hostname);
1534
1552
  if (project) {
1535
1553
  const { status: pStatus, json: pJson } = await accountCall("POST", "/api/namespace/set-project", { namespace_id: ns, project });
@@ -1637,7 +1655,165 @@ you design the team, provision its agents, launch them, and manage them.
1637
1655
  console.log(`canonical_json: ${JSON.stringify(json.canonical_json)}`);
1638
1656
  process.exit(0);
1639
1657
  }
1640
- console.error("Usage: patchcord team blueprint current --namespace <ns> [--json]");
1658
+ if (bsub === "push") {
1659
+ const ns = flagVal("namespace");
1660
+ const file = flagVal("file");
1661
+ const idem = flagVal("idempotency-key");
1662
+ const wantJson = process.argv.includes("--json");
1663
+ if (!ns || !file) { console.error("Usage: patchcord team blueprint push --namespace <ns> --file <blueprint.json> [--idempotency-key k] [--json]"); process.exit(1); }
1664
+ let doc;
1665
+ try { doc = JSON.parse(readFileSync(file, "utf-8")); }
1666
+ catch (e) { console.error(`cannot read blueprint file: ${e.message}`); process.exit(1); }
1667
+ // First POST for an unowned namespace IS first-provision (server claims
1668
+ // the namespace atomically when PATCHCORD_BLUEPRINT_ATOMIC_CREATE is on).
1669
+ const payload = { canonical_json: doc };
1670
+ if (idem) payload.idempotency_key = idem;
1671
+ const { status, json, body } = await accountCall("POST", `/api/team/${encodeURIComponent(ns)}/blueprint/revisions`, payload);
1672
+ if (status !== "201" && status !== "200") {
1673
+ console.error(`blueprint push failed (HTTP ${status}): ${(json && (json.error || JSON.stringify(json.detail))) || body || ""}`);
1674
+ process.exit(1);
1675
+ }
1676
+ if (wantJson) {
1677
+ process.stdout.write(JSON.stringify({ ...json, created: status === "201" }) + "\n");
1678
+ process.exit(0);
1679
+ }
1680
+ console.log(`${status === "201" ? "created" : "unchanged (dedup)"} revision_uid: ${json.revision_uid}`);
1681
+ console.log(`sha256: ${json.sha256}`);
1682
+ process.exit(0);
1683
+ }
1684
+ console.error("Usage: patchcord team blueprint <current|push> --namespace <ns> [--json]");
1685
+ process.exit(1);
1686
+ }
1687
+ if (sub === "deployment" || sub === "deployments") {
1688
+ // Deploy = apply a saved revision to live panes. Deliberately NOT part of
1689
+ // a save: `blueprint push` writes an immutable revision, this asks the
1690
+ // owning Mux installation to enact one. Mux shells out to these; its
1691
+ // poller treats an unknown subcommand as an empty pending list, so an
1692
+ // older CLI degrades to "nothing to do" rather than crashing a host.
1693
+ const dsub = process.argv[4];
1694
+ const wantJson = process.argv.includes("--json");
1695
+ const fail = (label, status, json, body) => {
1696
+ const detail = (json && (json.error || json.code || JSON.stringify(json.detail))) || body || "";
1697
+ console.error(`deployment ${label} failed (HTTP ${status}): ${detail}`);
1698
+ process.exit(1);
1699
+ };
1700
+ const emit = (json) => {
1701
+ if (wantJson) { process.stdout.write(JSON.stringify(json) + "\n"); process.exit(0); }
1702
+ };
1703
+ const printJob = (j) => {
1704
+ console.log(`job_uid: ${j.job_uid}`);
1705
+ console.log(`namespace: ${j.namespace}`);
1706
+ console.log(`status: ${j.status}`);
1707
+ console.log(`revision_uid: ${j.revision_uid}`);
1708
+ console.log(`sha256: ${j.sha256}`);
1709
+ if (j.claimed_by) console.log(`claimed_by: ${j.claimed_by}`);
1710
+ if (j.result && Object.keys(j.result).length) console.log(`result: ${JSON.stringify(j.result)}`);
1711
+ };
1712
+
1713
+ if (dsub === "request") {
1714
+ const ns = flagVal("namespace");
1715
+ const rev = flagVal("revision");
1716
+ if (!ns) { console.error("Usage: patchcord team deployment request --namespace <ns> [--revision <uid>] [--json]"); process.exit(1); }
1717
+ const payload = rev ? { revision_uid: rev } : {};
1718
+ const { status, json, body } = await accountCall("POST", `/api/team/${encodeURIComponent(ns)}/deployments`, payload);
1719
+ // 409 is not an error to the caller: a deploy is already in flight and
1720
+ // the response carries it, so emit that job rather than failing. The
1721
+ // exit code still distinguishes the two.
1722
+ if (status === "409" && json && json.job) {
1723
+ if (wantJson) { process.stdout.write(JSON.stringify({ ...json.job, already_active: true }) + "\n"); process.exit(3); }
1724
+ console.log(`already active (${json.code})`);
1725
+ printJob(json.job);
1726
+ process.exit(3);
1727
+ }
1728
+ if (status !== "200" && status !== "201") fail("request", status, json, body);
1729
+ emit(json);
1730
+ printJob(json);
1731
+ process.exit(0);
1732
+ }
1733
+
1734
+ if (dsub === "pending") {
1735
+ const { status, json, body } = await accountCall("GET", "/api/deployments/pending");
1736
+ if (status !== "200") fail("pending", status, json, body);
1737
+ emit(json);
1738
+ const jobs = (json && json.jobs) || [];
1739
+ if (!jobs.length) { console.log("(no pending deployments)"); process.exit(0); }
1740
+ for (const j of jobs) console.log(` ${j.job_uid} ${j.namespace} ${j.sha256.slice(0, 12)} ${j.status}`);
1741
+ process.exit(0);
1742
+ }
1743
+
1744
+ if (dsub === "claim") {
1745
+ const job = flagVal("job");
1746
+ const installation = flagVal("installation");
1747
+ if (!job) { console.error("Usage: patchcord team deployment claim --job <uid> [--installation <name>] [--json]"); process.exit(1); }
1748
+ const { status, json, body } = await accountCall(
1749
+ "POST", `/api/deployments/${encodeURIComponent(job)}/claim`,
1750
+ installation ? { installation } : {},
1751
+ );
1752
+ // Losing a claim race is normal in a multi-host account, not a failure:
1753
+ // exit 3 so a poller can skip to the next job without parsing stderr.
1754
+ if (status === "409") {
1755
+ if (wantJson) { process.stdout.write(JSON.stringify(json) + "\n"); process.exit(3); }
1756
+ console.log(`not claimable (${(json && json.code) || "conflict"})`);
1757
+ process.exit(3);
1758
+ }
1759
+ if (status !== "200") fail("claim", status, json, body);
1760
+ emit(json);
1761
+ printJob(json);
1762
+ process.exit(0);
1763
+ }
1764
+
1765
+ if (dsub === "complete") {
1766
+ const job = flagVal("job");
1767
+ const st = flagVal("status");
1768
+ const resultRaw = flagVal("result");
1769
+ const resultFile = flagVal("result-file");
1770
+ if (!job || !st) {
1771
+ console.error("Usage: patchcord team deployment complete --job <uid> --status <healthy|degraded|cancelled> [--result <json> | --result-file <path>] [--json]");
1772
+ process.exit(1);
1773
+ }
1774
+ let result = {};
1775
+ try {
1776
+ if (resultFile) result = JSON.parse(readFileSync(resultFile, "utf-8"));
1777
+ else if (resultRaw) result = JSON.parse(resultRaw);
1778
+ } catch (e) { console.error(`cannot parse result JSON: ${e.message}`); process.exit(1); }
1779
+ const { status, json, body } = await accountCall(
1780
+ "POST", `/api/deployments/${encodeURIComponent(job)}/complete`,
1781
+ { status: st, result },
1782
+ );
1783
+ if (status === "409") {
1784
+ // The lease lapsed and the job was requeued or failed while this host
1785
+ // was applying. Reporting into it would overwrite a fresher attempt.
1786
+ if (wantJson) { process.stdout.write(JSON.stringify(json) + "\n"); process.exit(3); }
1787
+ console.log(`not running (${(json && json.code) || "conflict"}) — lease likely lapsed`);
1788
+ process.exit(3);
1789
+ }
1790
+ if (status !== "200") fail("complete", status, json, body);
1791
+ emit(json);
1792
+ printJob(json);
1793
+ process.exit(0);
1794
+ }
1795
+
1796
+ if (dsub === "get") {
1797
+ const ns = flagVal("namespace");
1798
+ const job = flagVal("job");
1799
+ const active = process.argv.includes("--active");
1800
+ if (!ns || (!job && !active)) {
1801
+ console.error("Usage: patchcord team deployment get --namespace <ns> (--job <uid> | --active) [--json]");
1802
+ process.exit(1);
1803
+ }
1804
+ const path = active
1805
+ ? `/api/team/${encodeURIComponent(ns)}/deployments?active=1`
1806
+ : `/api/team/${encodeURIComponent(ns)}/deployments/${encodeURIComponent(job)}`;
1807
+ const { status, json, body } = await accountCall("GET", path);
1808
+ if (status !== "200") fail("get", status, json, body);
1809
+ emit(json);
1810
+ const j = active ? (json && json.job) : json;
1811
+ if (!j) { console.log("(no active deployment)"); process.exit(0); }
1812
+ printJob(j);
1813
+ process.exit(0);
1814
+ }
1815
+
1816
+ console.error("Usage: patchcord team deployment <request|pending|claim|complete|get> [--json]");
1641
1817
  process.exit(1);
1642
1818
  }
1643
1819
  if (sub === "status") {
@@ -1776,7 +1952,7 @@ you design the team, provision its agents, launch them, and manage them.
1776
1952
  }
1777
1953
  process.exit(0);
1778
1954
  }
1779
- console.error("Usage: patchcord team <list|blueprint|launch|status>");
1955
+ console.error("Usage: patchcord team <list|blueprint|deployment|launch|status>");
1780
1956
  process.exit(1);
1781
1957
  }
1782
1958
 
@@ -2158,6 +2334,40 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
2158
2334
  if (_approveCursorProjectMcp(process.cwd())) {
2159
2335
  globalChanges.push("Cursor MCP approved for this project");
2160
2336
  }
2337
+
2338
+ // Stop hook — a SECOND wake path, independent of the subscribe listener.
2339
+ // The listener's notify_on_output wakes a seat both busy and idle
2340
+ // (verified live), so an armed listener needs no help. This covers the
2341
+ // case where no listener is running at all: never armed, armed without
2342
+ // notify_on_output, or the process died. The `stop` hook fires on the
2343
+ // harness's own event and needs no long-lived process, so it survives
2344
+ // exactly the failure that silences subscribe.
2345
+ // NOTE: cursor-agent loads ~/.cursor/hooks.json at STARTUP only — a
2346
+ // session already running when this is written will not fire it.
2347
+ const cursorHookSrc = join(pluginRoot, "scripts", "cursor-stop-hook.sh");
2348
+ if (existsSync(cursorHookSrc)) {
2349
+ const cursorHookDest = join(HOME, ".cursor", "patchcord-stop-hook.sh");
2350
+ const hookExisted = existsSync(cursorHookDest);
2351
+ mkdirSync(join(HOME, ".cursor"), { recursive: true });
2352
+ cpSync(cursorHookSrc, cursorHookDest);
2353
+ chmodSync(cursorHookDest, 0o755);
2354
+
2355
+ const cursorHooksPath = join(HOME, ".cursor", "hooks.json");
2356
+ const hooksOk = updateJsonConfig(cursorHooksPath, (obj) => {
2357
+ if (obj.version === undefined) obj.version = 1;
2358
+ if (!obj.hooks || typeof obj.hooks !== "object" || Array.isArray(obj.hooks)) obj.hooks = {};
2359
+ const existing = Array.isArray(obj.hooks.stop) ? obj.hooks.stop : [];
2360
+ // Drop any prior patchcord entry (path may have moved) and re-add.
2361
+ const kept = existing.filter((e) => !JSON.stringify(e).includes("patchcord-stop-hook"));
2362
+ kept.push({ command: cursorHookDest });
2363
+ obj.hooks.stop = kept;
2364
+ });
2365
+ if (!hooksOk) {
2366
+ globalChanges.push("✗ Cursor stop hook error");
2367
+ } else {
2368
+ globalChanges.push(`Cursor stop hook ${hookExisted ? "updated" : "installed"}`);
2369
+ }
2370
+ }
2161
2371
  }
2162
2372
  }
2163
2373
 
@@ -2486,6 +2696,15 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
2486
2696
  copyFileSync(hookScriptSrc, hookScriptDest);
2487
2697
  chmodSync(hookScriptDest, 0o755);
2488
2698
 
2699
+ // The hook sources this for the identity hint + loop brake. It lives in
2700
+ // scripts/lib/ in the repo, but the hook runs from ~/.codex, so ship a
2701
+ // copy beside it — otherwise the hook silently falls back to the older,
2702
+ // loop-prone wording.
2703
+ const guardSrc = join(pluginRoot, "scripts", "lib", "inbox-guard.sh");
2704
+ if (existsSync(guardSrc)) {
2705
+ copyFileSync(guardSrc, join(HOME, ".codex", "patchcord-inbox-guard.sh"));
2706
+ }
2707
+
2489
2708
  // Enable the `hooks` feature flag exactly once. Older installs / Codex
2490
2709
  // versions may leave `codex_hooks = true`, `hooks=true` (no spaces), a
2491
2710
  // `hooks = false`, or even a duplicate `hooks = true` — any of which makes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.6.27",
3
+ "version": "0.6.29",
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"
@@ -4,6 +4,18 @@ set -euo pipefail
4
4
  # jq is required — skip silently if not installed (fresh macOS)
5
5
  command -v jq >/dev/null 2>&1 || exit 0
6
6
 
7
+ HOOK_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
8
+ if [ -f "$HOOK_DIR/lib/inbox-guard.sh" ]; then
9
+ # shellcheck source=lib/inbox-guard.sh
10
+ . "$HOOK_DIR/lib/inbox-guard.sh"
11
+ else
12
+ # Degrade to the pre-guard behaviour rather than dying: a partial install
13
+ # must still nudge, just without the identity hint or the loop breaker.
14
+ pc_inbox_reason() { printf '%s patchcord message(s) waiting. Call inbox() and reply to all immediately.' "$1"; }
15
+ pc_streak_ok() { return 0; }
16
+ pc_state_path() { printf ''; }
17
+ fi
18
+
7
19
  INPUT=$(cat)
8
20
 
9
21
  # Validate input is parseable JSON before proceeding — Claude Code may include
@@ -118,22 +130,36 @@ fi
118
130
 
119
131
  # ── Inbox notification (deduplicated across Stop + Notification hooks) ──
120
132
  COUNT=$(echo "$RESPONSE" | jq -r '.count // .pending_count // 0' 2>/dev/null || echo "0")
133
+ case "$COUNT" in
134
+ ''|*[!0-9]*) COUNT=0 ;;
135
+ esac
121
136
 
122
- if [ "$COUNT" -gt 0 ]; then
123
- NOTIFY_LOCK="/tmp/patchcord_notify_lock"
124
- LOCK_AGE=5
125
- if [ -f "$NOTIFY_LOCK" ]; then
126
- LOCK_MTIME=$(stat -c %Y "$NOTIFY_LOCK" 2>/dev/null || stat -f %m "$NOTIFY_LOCK" 2>/dev/null || echo "0")
127
- NOW=$(date +%s)
128
- if [ $(( NOW - LOCK_MTIME )) -lt $LOCK_AGE ]; then
129
- exit 0 # Already notified within 5s
130
- fi
137
+ STREAK_STATE=$(pc_state_path "patchcord_inbox_streak" "$NAMESPACE" "$AGENT_ID" 2>/dev/null || echo "")
138
+
139
+ if [ "$COUNT" -eq 0 ]; then
140
+ [ -n "$STREAK_STATE" ] && rm -f "$STREAK_STATE"
141
+ exit 0
142
+ fi
143
+
144
+ NOTIFY_LOCK="/tmp/patchcord_notify_lock"
145
+ LOCK_AGE=5
146
+ if [ -f "$NOTIFY_LOCK" ]; then
147
+ LOCK_MTIME=$(stat -c %Y "$NOTIFY_LOCK" 2>/dev/null || stat -f %m "$NOTIFY_LOCK" 2>/dev/null || echo "0")
148
+ NOW=$(date +%s)
149
+ if [ $(( NOW - LOCK_MTIME )) -lt $LOCK_AGE ]; then
150
+ exit 0 # Already notified within 5s
131
151
  fi
132
- touch "$NOTIFY_LOCK"
133
- jq -n --arg count "$COUNT" '{
134
- "decision": "block",
135
- "reason": ($count + " patchcord message(s) waiting. Call inbox() and reply to all immediately.")
136
- }'
137
- else
152
+ fi
153
+
154
+ # Give up after MAX identical blocks. Keyed on identity+count, so a genuinely
155
+ # new message resets it and always gets through but a hook that keeps
156
+ # counting messages the session cannot see (stale on-disk token, see
157
+ # lib/inbox-guard.sh) stops burning turns instead of looping forever.
158
+ if [ -n "$STREAK_STATE" ] \
159
+ && ! pc_streak_ok "$STREAK_STATE" "${NAMESPACE}/${AGENT_ID}/${COUNT}" 3; then
138
160
  exit 0
139
161
  fi
162
+
163
+ touch "$NOTIFY_LOCK"
164
+ REASON=$(pc_inbox_reason "$COUNT" "$NAMESPACE" "$AGENT_ID" "inbox()" "restart this Claude Code session")
165
+ jq -n --arg reason "$REASON" '{"decision": "block", "reason": $reason}'
@@ -6,6 +6,22 @@ set -euo pipefail
6
6
 
7
7
  command -v jq >/dev/null 2>&1 || exit 0
8
8
 
9
+ # This script is COPIED to ~/.codex/patchcord-stop-hook.sh by the installer, so
10
+ # the shared guard is looked for both in the repo layout (scripts/lib/) and
11
+ # beside the installed copy, where `npx patchcord` drops it.
12
+ HOOK_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
13
+ if [ -f "$HOOK_DIR/lib/inbox-guard.sh" ]; then
14
+ # shellcheck source=lib/inbox-guard.sh
15
+ . "$HOOK_DIR/lib/inbox-guard.sh"
16
+ elif [ -f "$HOOK_DIR/patchcord-inbox-guard.sh" ]; then
17
+ # shellcheck source=lib/inbox-guard.sh
18
+ . "$HOOK_DIR/patchcord-inbox-guard.sh"
19
+ else
20
+ pc_inbox_reason() { printf '%s patchcord message(s) waiting. Call inbox() and reply to all.' "$1"; }
21
+ pc_streak_ok() { return 0; }
22
+ pc_state_path() { printf ''; }
23
+ fi
24
+
9
25
  INPUT=$(cat)
10
26
 
11
27
  PROJECT_CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
@@ -61,14 +77,32 @@ RESPONSE=$(cat /tmp/patchcord_codex_inbox.json 2>/dev/null || echo '{"count":0}'
61
77
  rm -f /tmp/patchcord_codex_inbox.json
62
78
 
63
79
  COUNT=$(echo "$RESPONSE" | jq -r '.count // .pending_count // 0' 2>/dev/null || echo "0")
80
+ case "$COUNT" in
81
+ ''|*[!0-9]*) COUNT=0 ;;
82
+ esac
83
+
84
+ NAMESPACE=$(echo "$RESPONSE" | jq -r '.namespace_id // empty' 2>/dev/null || true)
85
+ AGENT_ID=$(echo "$RESPONSE" | jq -r '.agent_id // empty' 2>/dev/null || true)
86
+ STREAK_STATE=$(pc_state_path "patchcord_codex_streak" "$NAMESPACE" "$AGENT_ID" 2>/dev/null || echo "")
87
+
88
+ if [ "$COUNT" -eq 0 ]; then
89
+ [ -n "$STREAK_STATE" ] && rm -f "$STREAK_STATE"
90
+ exit 0
91
+ fi
64
92
 
65
- if [ "$COUNT" -gt 0 ]; then
66
- NOTIFY_LOCK="/tmp/patchcord_codex_notify_lock"
67
- if [ -f "$NOTIFY_LOCK" ]; then
68
- LOCK_MTIME=$(stat -c %Y "$NOTIFY_LOCK" 2>/dev/null || stat -f %m "$NOTIFY_LOCK" 2>/dev/null || echo "0")
69
- NOW=$(date +%s)
70
- [ $(( NOW - LOCK_MTIME )) -lt 5 ] && exit 0
71
- fi
72
- touch "$NOTIFY_LOCK"
73
- jq -n --arg count "$COUNT" '{"decision":"block","reason":($count + " patchcord message(s) waiting. Call inbox() and reply to all.")}'
93
+ NOTIFY_LOCK="/tmp/patchcord_codex_notify_lock"
94
+ if [ -f "$NOTIFY_LOCK" ]; then
95
+ LOCK_MTIME=$(stat -c %Y "$NOTIFY_LOCK" 2>/dev/null || stat -f %m "$NOTIFY_LOCK" 2>/dev/null || echo "0")
96
+ NOW=$(date +%s)
97
+ [ $(( NOW - LOCK_MTIME )) -lt 5 ] && exit 0
74
98
  fi
99
+
100
+ # Same-count/same-identity blocks give up after 3 — see lib/inbox-guard.sh.
101
+ if [ -n "$STREAK_STATE" ] \
102
+ && ! pc_streak_ok "$STREAK_STATE" "${NAMESPACE}/${AGENT_ID}/${COUNT}" 3; then
103
+ exit 0
104
+ fi
105
+
106
+ touch "$NOTIFY_LOCK"
107
+ REASON=$(pc_inbox_reason "$COUNT" "$NAMESPACE" "$AGENT_ID" "inbox()" "restart this Codex session")
108
+ jq -n --arg reason "$REASON" '{"decision":"block","reason":$reason}'
@@ -0,0 +1,167 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # Cursor `stop` hook — checks the patchcord inbox at the end of every turn.
5
+ # Installed automatically by `npx patchcord` when cursor-agent is detected.
6
+ #
7
+ # Why this exists: cursor-agent has no push-from-background-shell mechanism.
8
+ # Its Shell tool exposes only the pull-shaped Await contract
9
+ # (task_id / block_until_ms / regex), so a background `patchcord subscribe`
10
+ # listener can print "PATCHCORD:" lines that nothing ever reads. Hooks are the
11
+ # one inbound path Cursor actually offers, and `stop` is the one that fires
12
+ # when the agent goes idle — exactly when an unread message would otherwise sit
13
+ # until a human pokes the session.
14
+ #
15
+ # Contract — captured from a live cursor-agent 2026.07.23 stop event, not from
16
+ # docs. The real stdin payload is:
17
+ # {conversation_id, generation_id, model, status, loop_count, input_tokens,
18
+ # output_tokens, cache_read_tokens, cache_write_tokens, session_id,
19
+ # hook_event_name, cursor_version, workspace_roots[], user_email,
20
+ # transcript_path}
21
+ # Note `workspace_roots` — an ARRAY, and there is no `workspace_root_path` and
22
+ # no `cwd`. Reading a scalar here silently falls back to $PWD (whatever cwd the
23
+ # hook process inherited) and resolves the wrong project, or none.
24
+ #
25
+ # stdout — JSON; a `followup_message` field starts a new turn carrying that
26
+ # text (StopRequestResponse.followupMessage). Verified live: it does
27
+ # start turns, and each new turn ends and fires this hook again — a
28
+ # bare canary looped 5 times in 14s before Cursor's own loop_count
29
+ # cap stopped it. That is why the streak brake below is not optional.
30
+ # Never exit nonzero: a failing hook must not break the session.
31
+ #
32
+ # Hooks are loaded at STARTUP only. Dropping this file into ~/.cursor while a
33
+ # session is running does nothing until that session restarts (verified: the
34
+ # same canary was silent mid-session and fired immediately after a restart).
35
+
36
+ command -v jq >/dev/null 2>&1 || exit 0
37
+ command -v curl >/dev/null 2>&1 || exit 0
38
+
39
+ INPUT=$(cat 2>/dev/null || echo '{}')
40
+
41
+ # workspace_roots[0] is what cursor-agent actually sends. The scalar spellings
42
+ # are accepted as fallbacks in case the payload shape changes again; $PWD is the
43
+ # last resort and is deliberately last, since it points at the hook process's
44
+ # inherited cwd rather than the agent's project.
45
+ PROJECT_CWD=$(echo "$INPUT" | jq -r '(.workspace_roots[0]? // .workspace_root_path? // .cwd?) // empty' 2>/dev/null || true)
46
+ if [ -z "$PROJECT_CWD" ] || [ "$PROJECT_CWD" = "null" ]; then
47
+ PROJECT_CWD="$PWD"
48
+ fi
49
+
50
+ CONVERSATION=$(echo "$INPUT" | jq -r '.conversation_id // empty' 2>/dev/null || true)
51
+ [ -n "$CONVERSATION" ] || CONVERSATION="nocid"
52
+
53
+ # ── Resolve project-scoped .cursor/mcp.json ──────────────────────────────────
54
+ # PROJECT-scoped ONLY — walk up from the workspace root. A cursor-agent started
55
+ # outside a patchcord project is not that agent and must not be nudged.
56
+ CURSOR_MCP=""
57
+ dir="$PROJECT_CWD"
58
+ while [ "$dir" != "/" ] && [ -n "$dir" ]; do
59
+ if [ -f "$dir/.cursor/mcp.json" ]; then
60
+ CURSOR_MCP="$dir/.cursor/mcp.json"
61
+ break
62
+ fi
63
+ parent=$(dirname "$dir")
64
+ [ "$parent" = "$dir" ] && break
65
+ dir="$parent"
66
+ done
67
+ [ -n "$CURSOR_MCP" ] || exit 0
68
+
69
+ TOKEN=$(jq -r '.mcpServers.patchcord.headers.Authorization // empty' "$CURSOR_MCP" 2>/dev/null | sed 's/^Bearer //i' || true)
70
+ URL=$(jq -r '.mcpServers.patchcord.url // empty' "$CURSOR_MCP" 2>/dev/null || true)
71
+ if [ -z "$TOKEN" ] || [ -z "$URL" ]; then
72
+ exit 0
73
+ fi
74
+
75
+ # Cursor uses the bearer endpoint; both spellings normalize to the same base.
76
+ BASE_URL=$(echo "$URL" | sed 's|/mcp/bearer$||; s|/mcp$||')
77
+
78
+ # ── Suppression state, keyed per project+conversation ────────────────────────
79
+ # One file holding "<epoch> <consecutive_nudges>". Two jobs:
80
+ # * rate limit — never nudge more than once per COOLDOWN seconds
81
+ # * loop brake — if the agent is handed the same nudge MAX_CONSECUTIVE times
82
+ # without the inbox draining, stop; something is wrong and an
83
+ # endless followup chain would burn the session.
84
+ # Both reset the moment the inbox reaches zero.
85
+ #
86
+ # Keyed on the RESOLVED project root (the directory owning .cursor/mcp.json),
87
+ # never on the incoming workspace path: Cursor reports whatever cwd the turn ran
88
+ # in, so keying on that would mint a fresh key per subdirectory and silently
89
+ # defeat both the cooldown and the loop brake.
90
+ COOLDOWN=30
91
+ MAX_CONSECUTIVE=3
92
+ PROJECT_ROOT=$(dirname "$(dirname "$CURSOR_MCP")")
93
+ STATE_KEY=$(printf '%s|%s' "$PROJECT_ROOT" "$CONVERSATION" | cksum | cut -d' ' -f1)
94
+ STATE_FILE="/tmp/patchcord_cursor_stop_${STATE_KEY}"
95
+
96
+ # ── Pending count ────────────────────────────────────────────────────────────
97
+ BODY_FILE=$(mktemp "/tmp/patchcord_cursor_inbox.XXXXXX" 2>/dev/null || echo "/tmp/patchcord_cursor_inbox.$$")
98
+ HTTP_CODE=$(curl -s -o "$BODY_FILE" -w "%{http_code}" --max-time 5 \
99
+ -H "Authorization: Bearer ${TOKEN}" \
100
+ -H "x-patchcord-install-path: ${PROJECT_CWD}" \
101
+ "${BASE_URL}/api/inbox?status=pending&limit=5&count_only=1" 2>/dev/null || echo "000")
102
+
103
+ RESPONSE=$(cat "$BODY_FILE" 2>/dev/null || echo '{}')
104
+ rm -f "$BODY_FILE"
105
+
106
+ if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "403" ]; then
107
+ # Surface a dead token once per cooldown rather than silently going quiet.
108
+ NOW=$(date +%s)
109
+ LAST=0
110
+ [ -f "$STATE_FILE" ] && LAST=$(cut -d' ' -f1 "$STATE_FILE" 2>/dev/null || echo 0)
111
+ if [ $(( NOW - LAST )) -ge "$COOLDOWN" ]; then
112
+ echo "$NOW 0" > "$STATE_FILE"
113
+ jq -n --arg code "$HTTP_CODE" \
114
+ '{followup_message: ("Patchcord token rejected (HTTP " + $code + "). Tell the human to re-run `npx patchcord` in this project — do not edit .cursor/mcp.json by hand.")}'
115
+ fi
116
+ exit 0
117
+ fi
118
+
119
+ # Network blip / origin down — stay silent, try again next turn.
120
+ [ "$HTTP_CODE" = "200" ] || exit 0
121
+
122
+ COUNT=$(echo "$RESPONSE" | jq -r '.pending_count // .count // 0' 2>/dev/null || echo "0")
123
+ case "$COUNT" in
124
+ ''|*[!0-9]*) COUNT=0 ;;
125
+ esac
126
+
127
+ if [ "$COUNT" -eq 0 ]; then
128
+ rm -f "$STATE_FILE"
129
+ exit 0
130
+ fi
131
+
132
+ NOW=$(date +%s)
133
+ LAST=0
134
+ STREAK=0
135
+ if [ -f "$STATE_FILE" ]; then
136
+ LAST=$(cut -d' ' -f1 "$STATE_FILE" 2>/dev/null || echo 0)
137
+ STREAK=$(cut -d' ' -f2 "$STATE_FILE" 2>/dev/null || echo 0)
138
+ case "$LAST" in ''|*[!0-9]*) LAST=0 ;; esac
139
+ case "$STREAK" in ''|*[!0-9]*) STREAK=0 ;; esac
140
+ fi
141
+
142
+ [ $(( NOW - LAST )) -lt "$COOLDOWN" ] && exit 0
143
+ [ "$STREAK" -ge "$MAX_CONSECUTIVE" ] && exit 0
144
+
145
+ echo "$NOW $(( STREAK + 1 ))" > "$STATE_FILE"
146
+
147
+ NAMESPACE=$(echo "$RESPONSE" | jq -r '.namespace_id // empty' 2>/dev/null || true)
148
+ AGENT_ID=$(echo "$RESPONSE" | jq -r '.agent_id // empty' 2>/dev/null || true)
149
+
150
+ if [ "$COUNT" -eq 1 ]; then
151
+ MSG="Patchcord: 1 pending message. Call the patchcord inbox tool now, do the work it asks for, then reply with what you did."
152
+ else
153
+ MSG="Patchcord: ${COUNT} pending messages. Call the patchcord inbox tool now, do the work each one asks for, then reply to each with what you did."
154
+ fi
155
+
156
+ # Name the identity the count belongs to. This hook reads the token from
157
+ # .cursor/mcp.json on disk; the agent's MCP connection holds whatever token it
158
+ # was given at session start. Once anything rewrites that config the two drift
159
+ # apart, the hook counts for the new identity and the inbox tool answers 0 for
160
+ # the old one — a contradiction neither side can see alone. See
161
+ # lib/inbox-guard.sh.
162
+ if [ -n "$NAMESPACE" ] && [ -n "$AGENT_ID" ]; then
163
+ MSG="${MSG} These are addressed to ${AGENT_ID}@${NAMESPACE}. If the inbox tool reports a different identity, or reports 0 pending, this session is authenticated as another agent — the token on disk was replaced after it started. Do not keep retrying: say so and ask the user to restart the session."
164
+ fi
165
+
166
+ jq -n --arg msg "$MSG" '{followup_message: $msg}'
167
+ exit 0
@@ -0,0 +1,88 @@
1
+ #!/bin/bash
2
+ # Shared stop-hook guards for the pending-message nudge.
3
+ #
4
+ # Sourced by check-inbox.sh (Claude Code), codex-stop-hook.sh and
5
+ # cursor-stop-hook.sh. Pure functions + a state file; no network, no jq
6
+ # dependency of its own.
7
+ #
8
+ # Why this exists — the stale-identity loop:
9
+ # A stop hook reads the bearer token from the config file ON DISK. The agent's
10
+ # MCP connection holds the token it was given when the session STARTED. Those
11
+ # are the same token right up until something rewrites the config — a
12
+ # re-provision, a team restore, a seat handover. From that moment the hook
13
+ # counts messages for the NEW identity while inbox() still queries as the OLD
14
+ # one, so the hook says "2 waiting" and inbox() truthfully answers 0. The
15
+ # agent has no way to see the contradiction, so it calls inbox() again, gets 0
16
+ # again, and burns turns until a human kills it.
17
+ #
18
+ # Neither side is wrong and neither can detect it alone. So the hook names the
19
+ # identity the count belongs to, which turns an invisible contradiction into
20
+ # something the agent can read and act on, and gives up after a few identical
21
+ # blocks instead of looping forever.
22
+
23
+ # pc_inbox_reason <count> <namespace> <agent> <inbox_call> <restart_hint>
24
+ #
25
+ # The nudge text. Names the identity so a session authenticated as a different
26
+ # agent can recognise the mismatch. Falls back to the plain wording when the
27
+ # server did not return an identity.
28
+ pc_inbox_reason() {
29
+ local count="$1" ns="$2" agent="$3" call="${4:-inbox()}" hint="${5:-restart this session}"
30
+ local noun="message(s)"
31
+ [ "$count" = "1" ] && noun="message"
32
+
33
+ if [ -z "$ns" ] || [ -z "$agent" ]; then
34
+ printf '%s patchcord %s waiting. Call %s and reply to all immediately.' \
35
+ "$count" "$noun" "$call"
36
+ return
37
+ fi
38
+
39
+ printf '%s patchcord %s waiting for %s@%s. Call %s and reply to all immediately. If %s reports a different identity than %s@%s, or reports 0 pending, then this session is authenticated as another agent: the token on disk was replaced after the session started. Do NOT keep calling %s — say so and ask the user to %s so it reloads the config.' \
40
+ "$count" "$noun" "$agent" "$ns" \
41
+ "$call" \
42
+ "$call" "$agent" "$ns" \
43
+ "$call" "$hint"
44
+ }
45
+
46
+ # pc_streak_ok <state_file> <key> <max>
47
+ #
48
+ # Consecutive-identical-block breaker. `key` should encode identity + count, so
49
+ # the streak resets the moment either changes and a genuinely new message always
50
+ # gets through. Returns 0 (block) while the streak is under `max`, 1 (stay
51
+ # silent) once it is reached. Writes state best-effort; an unwritable /tmp
52
+ # degrades to "always block", never to a hard failure.
53
+ pc_streak_ok() {
54
+ local state="$1" key="$2" max="${3:-3}"
55
+ local prev="" prev_key="" prev_n=0
56
+
57
+ prev=$(cat "$state" 2>/dev/null || true)
58
+ if [ -n "$prev" ]; then
59
+ prev_key="${prev%|*}"
60
+ prev_n="${prev##*|}"
61
+ case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
62
+ fi
63
+
64
+ if [ "$prev_key" = "$key" ]; then
65
+ if [ "$prev_n" -ge "$max" ]; then
66
+ return 1
67
+ fi
68
+ prev_n=$(( prev_n + 1 ))
69
+ else
70
+ prev_n=1
71
+ fi
72
+
73
+ # Braces so the REDIRECT's own failure is captured too — `printf > bad 2>/dev/null`
74
+ # still prints the redirect error, and kimi's hook treats stderr as the
75
+ # notification channel.
76
+ { printf '%s|%s' "$key" "$prev_n" > "$state"; } 2>/dev/null || true
77
+ return 0
78
+ }
79
+
80
+ # pc_state_path <prefix> <namespace> <agent>
81
+ # A filesystem-safe state path. Identity is part of the name so two agents in
82
+ # one project never share a streak counter.
83
+ pc_state_path() {
84
+ local prefix="$1" ns="${2:-nons}" agent="${3:-noagent}"
85
+ local slug
86
+ slug=$(printf '%s_%s' "$ns" "$agent" | tr -c 'A-Za-z0-9_.-' '_')
87
+ printf '/tmp/%s_%s' "$prefix" "$slug"
88
+ }