patchcord 0.3.60 → 0.3.62

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,10 +1,13 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "description": "Cross-machine agent messaging with auto-inbox checking. Agents automatically respond to messages from other agents without human intervention.",
4
- "version": "0.3.28",
3
+ "description": "Cross-machine agent messaging with push delivery. Messages from other agents arrive as native channel notifications.",
4
+ "version": "0.4.0",
5
5
  "author": {
6
6
  "name": "ppravdin"
7
7
  },
8
8
  "repository": "https://github.com/ppravdin/patchcord",
9
- "skills": "./skills/"
9
+ "skills": "./skills/",
10
+ "channel": {
11
+ "server": "./channel/server.ts"
12
+ }
10
13
  }
package/README.md CHANGED
@@ -1,103 +1,38 @@
1
- # Patchcord Plugin for Claude Code
1
+ # Patchcord Plugin
2
2
 
3
- Cross-machine messaging between Claude Code agents.
3
+ Cross-machine messaging between AI coding agents.
4
4
 
5
- This plugin is not the connection itself.
6
-
7
- The plugin provides:
8
-
9
- - Patchcord skills
10
- - statusline integration
11
- - turn-end inbox checks
12
-
13
- The actual Patchcord connection must still come from the current project configuration.
14
-
15
- ## Safe model
16
-
17
- Use this plugin with project-local Patchcord config.
18
-
19
- Good:
20
-
21
- - install the plugin once
22
- - keep `.mcp.json` inside each Patchcord-enabled project
23
- - let the plugin no-op in projects that do not have Patchcord configured
24
-
25
- Bad:
26
-
27
- - exporting `PATCHCORD_TOKEN` / `PATCHCORD_URL` globally in `~/.bashrc`, `~/.profile`, or similar
28
- - keeping Patchcord config in an ancestor directory like `~/.mcp.json`
29
- - assuming the plugin should make every project a Patchcord project
30
-
31
- ## Setup
32
-
33
- ### 1. Install the plugin
5
+ ## Install
34
6
 
35
7
  ```bash
36
- claude plugin marketplace add /path/to/patchcord-internal
37
- claude plugin install patchcord@patchcord-marketplace
8
+ npx patchcord@latest
38
9
  ```
39
10
 
40
- ### 2. Configure the project
11
+ One command. Opens browser, configures everything. Works with Claude Code, Codex CLI, Cursor, Windsurf, Gemini CLI, VS Code, Zed, OpenCode.
41
12
 
42
- Create a project-local `.mcp.json` in the project that should act as a Patchcord agent.
13
+ Self-hosted:
43
14
 
44
- Example:
45
-
46
- ```json
47
- {
48
- "mcpServers": {
49
- "patchcord": {
50
- "type": "http",
51
- "url": "https://patchcord.yourdomain.com/mcp",
52
- "headers": {
53
- "Authorization": "Bearer <project-token>"
54
- }
55
- }
56
- }
57
- }
15
+ ```bash
16
+ npx patchcord@latest --token <token> --server https://patchcord.yourdomain.com
58
17
  ```
59
18
 
60
- ### 3. Restart Claude Code in that project
61
-
62
- The plugin and statusline scripts read the current project configuration when the session starts.
63
-
64
- ## What happens in non-Patchcord projects
65
-
66
- Nothing Patchcord-specific should appear.
19
+ ## What it provides
67
20
 
68
- - no Patchcord identity in the statusline
69
- - no inbox checks
70
- - no hook-driven Patchcord prompts
21
+ - **Skills** patchcord inbox and wait skills for all supported tools
22
+ - **Statusline** shows agent identity in Claude Code statusbar
23
+ - **Stop hook** checks inbox between turns, notifies of pending messages
24
+ - **Slash commands** — `/patchcord` and `/patchcord-wait` for Codex and Gemini CLI
25
+ - **MCP config** — per-project or global config depending on tool
71
26
 
72
- The plugin is allowed to stay installed globally, but it must no-op unless the current project is configured.
27
+ ## How it works
73
28
 
74
- ## Self-hosted server
29
+ The installer:
30
+ 1. Detects installed tools and installs global components (skills, permissions, statusline)
31
+ 2. Opens browser for project + agent setup (or uses `--token` for self-hosted)
32
+ 3. Writes the correct MCP config for the chosen tool
75
33
 
76
- The project `.mcp.json` should point to your own server URL:
77
-
78
- ```json
79
- {
80
- "mcpServers": {
81
- "patchcord": {
82
- "type": "http",
83
- "url": "https://patchcord.yourdomain.com/mcp",
84
- "headers": {
85
- "Authorization": "Bearer <project-token>"
86
- }
87
- }
88
- }
89
- }
90
- ```
34
+ The plugin no-ops in projects without patchcord configured.
91
35
 
92
36
  ## Verify
93
37
 
94
- In a Patchcord-enabled project:
95
-
96
- - statusline should show the Patchcord identity
97
- - `inbox()` should return the expected `namespace_id` and `agent_id`
98
-
99
- In an unrelated project:
100
-
101
- - statusline should not show Patchcord identity
102
- - no Patchcord hooks should fire
103
- - no Patchcord tools should be present unless that project is configured
38
+ After setup, restart your tool session and say `check inbox`. Verify `agent_id` and `namespace_id` are correct.
package/bin/patchcord.mjs CHANGED
@@ -676,7 +676,7 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd === "--token" || cmd ===
676
676
  console.log(`\n ${green}✓${r} Codex configured: ${dim}${configPath}${r}`);
677
677
  console.log(` ${green}✓${r} Slash commands: ${dim}/patchcord${r}, ${dim}/patchcord-wait${r}`);
678
678
  } else {
679
- // Claude Code: write .mcp.json
679
+ // Claude Code: write .mcp.json (MCP server only — channel plugin disabled)
680
680
  const mcpPath = join(cwd, ".mcp.json");
681
681
  const mcpConfig = {
682
682
  mcpServers: {
@@ -733,6 +733,30 @@ if (!cmd || cmd === "install" || cmd === "agent" || cmd === "--token" || cmd ===
733
733
  process.exit(0);
734
734
  }
735
735
 
736
+ // ── channel: spawn the channel MCP server (used by .mcp.json) ──
737
+ if (cmd === "channel") {
738
+ const channelScript = join(pluginRoot, "channel", "server.ts");
739
+ if (!existsSync(channelScript)) {
740
+ console.error("Channel server not found. Reinstall patchcord.");
741
+ process.exit(1);
742
+ }
743
+ // Prefer bun, fall back to node (tsx)
744
+ const hasBun = run("which bun");
745
+ if (hasBun) {
746
+ const { spawnSync } = await import("child_process");
747
+ // Install deps if needed
748
+ const channelDir = join(pluginRoot, "channel");
749
+ if (!existsSync(join(channelDir, "node_modules"))) {
750
+ spawnSync("bun", ["install", "--no-summary"], { cwd: channelDir, stdio: "inherit" });
751
+ }
752
+ const result = spawnSync("bun", ["run", channelScript], { stdio: "inherit", env: process.env });
753
+ process.exit(result.status ?? 1);
754
+ } else {
755
+ console.error("Channel plugin requires bun. Install from https://bun.sh");
756
+ process.exit(1);
757
+ }
758
+ }
759
+
736
760
  // ── back-compat: init → install + agent ───────────────────────
737
761
  if (cmd === "init") {
738
762
  console.log(`"patchcord init" is now two commands:
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "patchcord-channel",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "start": "bun install --no-summary && bun server.ts"
7
+ },
8
+ "dependencies": {
9
+ "@modelcontextprotocol/sdk": "^1.0.0"
10
+ }
11
+ }
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Patchcord channel for Claude Code.
4
+ *
5
+ * Polls the Patchcord server for new messages and pushes them as native
6
+ * <channel> notifications. Exposes reply and send_message tools for
7
+ * two-way communication.
8
+ *
9
+ * Config: PATCHCORD_TOKEN and PATCHCORD_SERVER env vars (set by .mcp.json).
10
+ */
11
+
12
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
13
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
14
+ import {
15
+ ListToolsRequestSchema,
16
+ CallToolRequestSchema,
17
+ } from '@modelcontextprotocol/sdk/types.js'
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Config
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const TOKEN = process.env.PATCHCORD_TOKEN ?? ''
24
+ const SERVER = (process.env.PATCHCORD_SERVER ?? 'https://mcp.patchcord.dev').replace(/\/+$/, '')
25
+ const POLL_INTERVAL_MS = 3000
26
+
27
+ if (!TOKEN) {
28
+ process.stderr.write(
29
+ `patchcord channel: PATCHCORD_TOKEN required\n` +
30
+ ` Set it in .mcp.json env block or as a shell environment variable.\n`,
31
+ )
32
+ process.exit(1)
33
+ }
34
+
35
+ // Safety nets
36
+ process.on('unhandledRejection', err => {
37
+ process.stderr.write(`patchcord channel: unhandled rejection: ${err}\n`)
38
+ })
39
+ process.on('uncaughtException', err => {
40
+ process.stderr.write(`patchcord channel: uncaught exception: ${err}\n`)
41
+ })
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Identity (resolved on startup)
45
+ // ---------------------------------------------------------------------------
46
+
47
+ let agentId = ''
48
+ let namespaceId = ''
49
+
50
+ async function resolveIdentity(): Promise<void> {
51
+ const res = await fetch(`${SERVER}/api/inbox?limit=0&count_only=true`, {
52
+ headers: { Authorization: `Bearer ${TOKEN}` },
53
+ })
54
+ if (!res.ok) {
55
+ const text = await res.text()
56
+ throw new Error(`identity check failed: ${res.status} ${text.slice(0, 200)}`)
57
+ }
58
+ const data = await res.json() as { agent_id: string; namespace_id: string }
59
+ agentId = data.agent_id
60
+ namespaceId = data.namespace_id
61
+ process.stderr.write(`patchcord channel: connected as ${agentId}@${namespaceId}\n`)
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // HTTP helpers
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const headers = {
69
+ Authorization: `Bearer ${TOKEN}`,
70
+ 'Content-Type': 'application/json',
71
+ }
72
+
73
+ async function channelPoll(): Promise<PollMessage[]> {
74
+ const res = await fetch(`${SERVER}/api/channel/poll`, {
75
+ method: 'POST',
76
+ headers,
77
+ body: JSON.stringify({}),
78
+ })
79
+ if (!res.ok) {
80
+ throw new Error(`poll failed: ${res.status}`)
81
+ }
82
+ return await res.json() as PollMessage[]
83
+ }
84
+
85
+ async function channelSend(to_agent: string, content: string): Promise<any> {
86
+ const res = await fetch(`${SERVER}/api/channel/send`, {
87
+ method: 'POST',
88
+ headers,
89
+ body: JSON.stringify({ to_agent, content }),
90
+ })
91
+ if (!res.ok) {
92
+ const text = await res.text()
93
+ throw new Error(`send failed: ${res.status} ${text.slice(0, 200)}`)
94
+ }
95
+ return await res.json()
96
+ }
97
+
98
+ async function channelReply(message_id: string, content: string): Promise<any> {
99
+ const res = await fetch(`${SERVER}/api/channel/reply`, {
100
+ method: 'POST',
101
+ headers,
102
+ body: JSON.stringify({ message_id, content }),
103
+ })
104
+ if (!res.ok) {
105
+ const text = await res.text()
106
+ throw new Error(`reply failed: ${res.status} ${text.slice(0, 200)}`)
107
+ }
108
+ return await res.json()
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Types
113
+ // ---------------------------------------------------------------------------
114
+
115
+ type PollMessage = {
116
+ id: string
117
+ from_agent: string
118
+ content: string
119
+ created_at: string
120
+ namespace_id: string
121
+ reply_to: string | null
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // MCP Server
126
+ // ---------------------------------------------------------------------------
127
+
128
+ const mcp = new Server(
129
+ { name: 'patchcord', version: '0.1.0' },
130
+ {
131
+ capabilities: {
132
+ experimental: { 'claude/channel': {} },
133
+ tools: {},
134
+ },
135
+ instructions: [
136
+ 'Messages from Patchcord agents arrive as <channel source="patchcord" from="..." message_id="..." namespace="...">.',
137
+ 'Reply with the reply tool, passing message_id from the notification.',
138
+ 'Use send_message to start new conversations with other agents.',
139
+ 'Messages arrive automatically - no need to call inbox or wait_for_message.',
140
+ ].join(' '),
141
+ },
142
+ )
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Tools
146
+ // ---------------------------------------------------------------------------
147
+
148
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
149
+ tools: [
150
+ {
151
+ name: 'reply',
152
+ description: 'Reply to a Patchcord message. Pass message_id from the <channel> notification.',
153
+ inputSchema: {
154
+ type: 'object' as const,
155
+ properties: {
156
+ message_id: { type: 'string', description: 'ID from the <channel> notification' },
157
+ content: { type: 'string', description: 'Reply text (up to 50,000 characters)' },
158
+ },
159
+ required: ['message_id', 'content'],
160
+ },
161
+ },
162
+ {
163
+ name: 'send_message',
164
+ description: 'Send a new message to a Patchcord agent. Use commas for multiple recipients.',
165
+ inputSchema: {
166
+ type: 'object' as const,
167
+ properties: {
168
+ to_agent: { type: 'string', description: 'Target agent name, optionally with @namespace' },
169
+ content: { type: 'string', description: 'Message text (up to 50,000 characters)' },
170
+ },
171
+ required: ['to_agent', 'content'],
172
+ },
173
+ },
174
+ {
175
+ name: 'inbox',
176
+ description: 'Check current Patchcord inbox. Shows pending messages. Normally not needed - messages arrive as push notifications.',
177
+ inputSchema: {
178
+ type: 'object' as const,
179
+ properties: {},
180
+ },
181
+ },
182
+ ],
183
+ }))
184
+
185
+ mcp.setRequestHandler(CallToolRequestSchema, async req => {
186
+ const { name } = req.params
187
+ const args = req.params.arguments as Record<string, string>
188
+
189
+ if (name === 'reply') {
190
+ if (!args.message_id || !args.content) {
191
+ return { content: [{ type: 'text', text: 'Error: message_id and content are required' }] }
192
+ }
193
+ try {
194
+ const result = await channelReply(args.message_id, args.content)
195
+ return { content: [{ type: 'text', text: `Replied to ${result.to_agent} [${result.id}]` }] }
196
+ } catch (err) {
197
+ return { content: [{ type: 'text', text: `Error: ${err}` }] }
198
+ }
199
+ }
200
+
201
+ if (name === 'send_message') {
202
+ if (!args.to_agent || !args.content) {
203
+ return { content: [{ type: 'text', text: 'Error: to_agent and content are required' }] }
204
+ }
205
+ try {
206
+ const result = await channelSend(args.to_agent, args.content)
207
+ return { content: [{ type: 'text', text: `Sent to ${result.to_agent} [${result.id}]` }] }
208
+ } catch (err) {
209
+ return { content: [{ type: 'text', text: `Error: ${err}` }] }
210
+ }
211
+ }
212
+
213
+ if (name === 'inbox') {
214
+ try {
215
+ const messages = await channelPoll()
216
+ if (messages.length === 0) {
217
+ return { content: [{ type: 'text', text: `${agentId}@${namespaceId} | 0 pending` }] }
218
+ }
219
+ const lines = [`${agentId}@${namespaceId} | ${messages.length} pending`]
220
+ for (const msg of messages) {
221
+ lines.push('')
222
+ lines.push(`From ${msg.from_agent} [${msg.id}]`)
223
+ lines.push(` ${msg.content}`)
224
+ // Push as notification too
225
+ await pushMessage(msg)
226
+ }
227
+ return { content: [{ type: 'text', text: lines.join('\n') }] }
228
+ } catch (err) {
229
+ return { content: [{ type: 'text', text: `Error: ${err}` }] }
230
+ }
231
+ }
232
+
233
+ throw new Error(`unknown tool: ${name}`)
234
+ })
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // Poll loop
238
+ // ---------------------------------------------------------------------------
239
+
240
+ let consecutiveFailures = 0
241
+ let connectionLostNotified = false
242
+
243
+ async function pushMessage(msg: PollMessage): Promise<void> {
244
+ const meta: Record<string, string> = {
245
+ from: msg.from_agent,
246
+ message_id: msg.id,
247
+ namespace: msg.namespace_id,
248
+ sent_at: msg.created_at,
249
+ }
250
+ if (msg.reply_to) {
251
+ meta.in_reply_to = msg.reply_to
252
+ }
253
+ await mcp.notification({
254
+ method: 'notifications/claude/channel',
255
+ params: { content: msg.content, meta },
256
+ })
257
+ }
258
+
259
+ async function poll(): Promise<void> {
260
+ try {
261
+ const messages = await channelPoll()
262
+ consecutiveFailures = 0
263
+ if (connectionLostNotified) {
264
+ connectionLostNotified = false
265
+ await mcp.notification({
266
+ method: 'notifications/claude/channel',
267
+ params: {
268
+ content: 'Patchcord connection restored.',
269
+ meta: { from: 'system', message_id: 'system' },
270
+ },
271
+ })
272
+ }
273
+ for (const msg of messages) {
274
+ await pushMessage(msg)
275
+ }
276
+ } catch (err) {
277
+ consecutiveFailures++
278
+ process.stderr.write(`patchcord channel: poll error (${consecutiveFailures}): ${err}\n`)
279
+
280
+ if (consecutiveFailures === 1) {
281
+ // Check if it's an auth error
282
+ const errStr = String(err)
283
+ if (errStr.includes('401')) {
284
+ process.stderr.write('patchcord channel: auth failed, stopping poll\n')
285
+ await mcp.notification({
286
+ method: 'notifications/claude/channel',
287
+ params: {
288
+ content: 'Patchcord auth failed. Check your token configuration.',
289
+ meta: { from: 'system', message_id: 'system' },
290
+ },
291
+ })
292
+ clearInterval(pollTimer)
293
+ return
294
+ }
295
+ }
296
+
297
+ if (consecutiveFailures >= 10 && !connectionLostNotified) {
298
+ connectionLostNotified = true
299
+ try {
300
+ await mcp.notification({
301
+ method: 'notifications/claude/channel',
302
+ params: {
303
+ content: 'Patchcord connection lost. Retrying...',
304
+ meta: { from: 'system', message_id: 'system' },
305
+ },
306
+ })
307
+ } catch {
308
+ // notification itself failed, give up
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Startup
316
+ // ---------------------------------------------------------------------------
317
+
318
+ // Resolve identity first
319
+ try {
320
+ await resolveIdentity()
321
+ } catch (err) {
322
+ process.stderr.write(`patchcord channel: failed to connect: ${err}\n`)
323
+ process.stderr.write('patchcord channel: will retry on first poll\n')
324
+ }
325
+
326
+ // Connect MCP over stdio
327
+ await mcp.connect(new StdioServerTransport())
328
+
329
+ // Drain existing messages, then start polling
330
+ await poll()
331
+ const pollTimer = setInterval(poll, POLL_INTERVAL_MS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchcord",
3
- "version": "0.3.60",
3
+ "version": "0.3.62",
4
4
  "description": "Cross-machine agent messaging for Claude Code and Codex",
5
5
  "author": "ppravdin",
6
6
  "license": "MIT",
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "files": [
24
24
  "bin/",
25
+ "channel/",
25
26
  ".claude-plugin/",
26
27
  "hooks/",
27
28
  "scripts/",
@@ -87,5 +87,6 @@ Deferred messages survive context compaction — the agent won't forget them.
87
87
  - If user says "check" or "check patchcord" — call inbox().
88
88
  - Presence is not a send or delivery gate. Agents may still receive messages while absent from the online list; use presence only as a recent-activity and routing hint.
89
89
  - send_message() is blocked by unread inbox items, not by offline status. If sending is blocked, clear actionable inbox items first.
90
- - Resolve machine names to agent_ids from inbox() results.
90
+ - Resolve machine names to agent_ids from inbox() results. The human operator is always `human` - never use their real name.
91
+ - Only send to agents that exist in your inbox online list. Don't guess agent names.
91
92
  - Do NOT reply to messages that don't need a response: acks, "ok", "noted", "seen", "👍", confirmations, thumbs up, "thanks", or anything that is clearly a conversation-ending signal. Just read them and move on. Only reply when the message asks a question, requests an action, or expects a deliverable.