patchcord 0.5.95 → 0.5.97

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
@@ -1225,16 +1225,19 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1225
1225
  copyFileSync(hookScriptSrc, hookScriptDest);
1226
1226
  chmodSync(hookScriptDest, 0o755);
1227
1227
 
1228
- // Enable hooks feature flag in config.toml if not already set
1229
- // Migrate any existing codex_hooks = true to hooks = true (deprecated)
1230
- if (globalCodexContent.includes("codex_hooks = true")) {
1231
- globalCodexContent = globalCodexContent.replace(/codex_hooks = true/g, "hooks = true");
1232
- } else if (!globalCodexContent.includes("hooks = true")) {
1233
- if (globalCodexContent.includes("[features]")) {
1234
- globalCodexContent = globalCodexContent.replace(/(\[features\])/, "$1\nhooks = true");
1235
- } else {
1236
- globalCodexContent = globalCodexContent.trimEnd() + "\n\n[features]\nhooks = true";
1237
- }
1228
+ // Enable the `hooks` feature flag exactly once. Older installs / Codex
1229
+ // versions may leave `codex_hooks = true`, `hooks=true` (no spaces), a
1230
+ // `hooks = false`, or even a duplicate `hooks = true` — any of which makes
1231
+ // config.toml fail to load with a "duplicate key" error. Strip every
1232
+ // feature-flag variant first, then add exactly one under [features].
1233
+ globalCodexContent = globalCodexContent
1234
+ .replace(/^[ \t]*(?:codex_)?hooks[ \t]*=[ \t]*(?:true|false)[ \t]*(?:#[^\n]*)?\r?\n?/gm, "")
1235
+ .replace(/\n{3,}/g, "\n\n")
1236
+ .trimEnd() + "\n";
1237
+ if (/^\[features\][ \t]*$/m.test(globalCodexContent)) {
1238
+ globalCodexContent = globalCodexContent.replace(/^(\[features\][ \t]*\r?\n)/m, "$1hooks = true\n");
1239
+ } else {
1240
+ globalCodexContent = globalCodexContent.trimEnd() + "\n\n[features]\nhooks = true\n";
1238
1241
  }
1239
1242
 
1240
1243
  // Remove any old patchcord stop hook entry from config.toml (moved to hooks.json)
@@ -1818,6 +1821,24 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd?.startsWith("--")) {
1818
1821
  if (ocOk) {
1819
1822
  console.log(`\n ${green}✓${r} OpenCode configured: ${dim}${ocPath}${r}`);
1820
1823
  }
1824
+ // Slash commands → .opencode/commands/
1825
+ try {
1826
+ const ocCmdDir = join(cwd, ".opencode", "commands");
1827
+ mkdirSync(ocCmdDir, { recursive: true });
1828
+ for (const f of ["patchcord-inbox.md", "patchcord-wait.md"]) {
1829
+ cpSync(join(pluginRoot, "commands", "opencode", f), join(ocCmdDir, f));
1830
+ }
1831
+ console.log(` ${green}✓${r} Commands installed: ${dim}/patchcord-inbox${r}, ${dim}/patchcord-wait${r}`);
1832
+ } catch {}
1833
+ // Realtime-wake plugin → .opencode/plugins/ (spawns `patchcord subscribe`
1834
+ // and injects a prompt via client.session.prompt on each new message).
1835
+ try {
1836
+ const ocPluginDir = join(cwd, ".opencode", "plugins");
1837
+ mkdirSync(ocPluginDir, { recursive: true });
1838
+ cpSync(join(pluginRoot, "plugins", "opencode", "patchcord.js"), join(ocPluginDir, "patchcord.js"));
1839
+ console.log(` ${green}✓${r} Realtime-wake plugin installed: ${dim}.opencode/plugins/patchcord.js${r}`);
1840
+ console.log(` ${dim}New Patchcord messages will wake the agent automatically.${r}`);
1841
+ } catch {}
1821
1842
  } else if (isOpenClaw) {
1822
1843
  // OpenClaw: global ~/.openclaw/openclaw.json → mcp.servers
1823
1844
  // Try CLI first, fall back to direct file write
@@ -0,0 +1,11 @@
1
+ ---
2
+ description: Check the Patchcord inbox and reply to messages from other agents
3
+ ---
4
+ Call the patchcord inbox tool. The first header line is YOUR identity (the recipient); each message's real sender is on its "From X" line — never confuse the header with the sender.
5
+
6
+ For each pending message, classify and act:
7
+ - ACK ("thanks", "noted", "ok", "works", "👍") → reply(message_id, resolve=true) with NO content. Never reply to an ack with text.
8
+ - BLOCKED (can't act right now) → reply(message_id, "<reason>", defer=true).
9
+ - ACTIONABLE → do the work first, THEN reply(message_id, "<concrete summary with file paths/results>").
10
+
11
+ Do the work before replying; never reply with a bare acknowledgement. Then tell me who wrote, what they asked, and what you did.
@@ -0,0 +1,8 @@
1
+ ---
2
+ description: Block and wait for one incoming Patchcord message (up to ~5 minutes)
3
+ ---
4
+ Call the patchcord wait_for_message tool — it blocks until a message arrives or ~5 minutes elapse.
5
+
6
+ If a message arrives, handle it the same way as /patchcord-inbox: ACK → reply(message_id, resolve=true) with no content; BLOCKED → reply(message_id, "<reason>", defer=true); ACTIONABLE → do the work first, then reply(message_id, "<concrete summary>").
7
+
8
+ If it times out with nothing, tell me nothing arrived.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.5.95",
3
+ "version": "0.5.97",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  "skills/",
29
29
  "per-project-skills/",
30
30
  "commands/",
31
- "README.md"
31
+ "README.md",
32
+ "plugins/"
32
33
  ]
33
34
  }
@@ -0,0 +1,88 @@
1
+ // Patchcord realtime-wake plugin for OpenCode.
2
+ //
3
+ // Spawns `patchcord subscribe` — the self-reconnecting Supabase realtime
4
+ // listener (shared with every other client) — and, on each incoming message,
5
+ // injects a prompt into the active session via client.session.prompt(). The
6
+ // agent wakes and checks its inbox. Real-time push, no re-arm.
7
+ //
8
+ // We prompt on the realtime event (not on session.idle) on purpose: re-prompting
9
+ // from session.idle has a documented teardown race in headless `opencode run`
10
+ // mode. Injecting on message arrival sidesteps it.
11
+ import { spawn } from "node:child_process";
12
+
13
+ export const PatchcordPlugin = async ({ client, directory }) => {
14
+ let sessionId = null;
15
+ let proc = null;
16
+
17
+ const wake = (from) => {
18
+ if (!sessionId) return; // no session to inject into yet; the listener's
19
+ // 60s pending-heartbeat re-emits, so nothing is lost.
20
+ client.session
21
+ .prompt({
22
+ path: { id: sessionId },
23
+ body: {
24
+ parts: [
25
+ {
26
+ type: "text",
27
+ text:
28
+ `You have a new Patchcord message from ${from}. Call the patchcord ` +
29
+ `inbox tool now and handle each pending message: an ACK ("thanks", ` +
30
+ `"ok", "noted", "works") -> reply(message_id, resolve=true) with NO ` +
31
+ `content; BLOCKED (can't act now) -> reply(message_id, "reason", ` +
32
+ `defer=true); ACTIONABLE -> do the work first, then ` +
33
+ `reply(message_id, "concrete summary"). Then tell me who wrote and ` +
34
+ `what you did.`,
35
+ },
36
+ ],
37
+ },
38
+ })
39
+ .catch(() => {});
40
+ };
41
+
42
+ const start = () => {
43
+ if (proc) return;
44
+ try {
45
+ proc = spawn("patchcord", ["subscribe"], {
46
+ cwd: directory,
47
+ stdio: ["ignore", "pipe", "ignore"],
48
+ });
49
+ } catch {
50
+ proc = null;
51
+ return;
52
+ }
53
+ let buf = "";
54
+ proc.stdout.on("data", (chunk) => {
55
+ buf += chunk.toString();
56
+ let nl;
57
+ while ((nl = buf.indexOf("\n")) >= 0) {
58
+ const line = buf.slice(0, nl);
59
+ buf = buf.slice(nl + 1);
60
+ if (line.startsWith("PATCHCORD:")) {
61
+ const m = line.match(/from (\S+)/);
62
+ wake(m ? m[1] : "another agent");
63
+ }
64
+ }
65
+ });
66
+ proc.on("error", () => {
67
+ proc = null;
68
+ });
69
+ proc.on("exit", () => {
70
+ proc = null;
71
+ });
72
+ };
73
+
74
+ // Start the listener as soon as the plugin loads.
75
+ start();
76
+
77
+ return {
78
+ event: async ({ event }) => {
79
+ const sid =
80
+ event?.session_id ||
81
+ event?.sessionID ||
82
+ event?.properties?.sessionID ||
83
+ event?.properties?.session_id ||
84
+ event?.properties?.info?.id;
85
+ if (sid) sessionId = sid;
86
+ },
87
+ };
88
+ };