tandem-editor 0.1.0
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/LICENSE +21 -0
- package/README.md +232 -0
- package/dist/channel/index.js +383 -0
- package/dist/channel/index.js.map +1 -0
- package/dist/cli/index.js +250 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/client/assets/index-fcpi1vLr.js +288 -0
- package/dist/client/index.html +17 -0
- package/dist/server/index.js +4681 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +118 -0
- package/sample/welcome.md +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bryan Kolb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/banner.png" alt="Tandem — Collaborative AI-Human Document Editor" width="800">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
A collaborative document editor where Claude and a human work on the same document in real-time -- editing, highlighting, commenting, and annotating together.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
```mermaid
|
|
10
|
+
graph LR
|
|
11
|
+
Browser["Browser<br/>Tiptap Editor"] <-->|WebSocket| Server["Tandem Server<br/>Node.js"]
|
|
12
|
+
Server <-->|MCP HTTP| Claude["Claude Code"]
|
|
13
|
+
Server <-->|File I/O| Files[".md .txt .docx"]
|
|
14
|
+
Server -->|SSE events| Shim["Channel Shim"]
|
|
15
|
+
Shim -->|push notifications| Claude
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Getting Started
|
|
19
|
+
|
|
20
|
+
### Prerequisites
|
|
21
|
+
|
|
22
|
+
- **Node.js 22+** ([download](https://nodejs.org))
|
|
23
|
+
- **Claude Code** (`irm https://claude.ai/install.ps1 | iex`)
|
|
24
|
+
|
|
25
|
+
### Option A: Global Install (recommended)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install -g tandem-editor
|
|
29
|
+
tandem setup # registers MCP tools with Claude Code / Claude Desktop
|
|
30
|
+
tandem # starts server + opens browser
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`tandem setup` auto-detects Claude Code and Claude Desktop and writes MCP configuration so tools work from any directory. Re-run after upgrading (`npm update -g tandem-editor`).
|
|
34
|
+
|
|
35
|
+
### Option B: Development Setup
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/bloknayrb/tandem.git
|
|
39
|
+
cd tandem
|
|
40
|
+
npm install
|
|
41
|
+
npm run dev:standalone # starts server (:3478/:3479) + browser client (:5173)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Open http://localhost:5173 -- you'll see `sample/welcome.md` loaded automatically on first run. The `.mcp.json` in the repo configures Claude Code automatically when run from this directory.
|
|
45
|
+
|
|
46
|
+
### Connect Claude Code
|
|
47
|
+
|
|
48
|
+
Start Claude Code and try:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
"Review the welcome document with me"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Claude calls `tandem_open`, the document appears in the browser, and annotations start flowing.
|
|
55
|
+
|
|
56
|
+
### Verify (if something seems wrong)
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm run doctor # from the repo, or after global install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This checks Node.js version, MCP configuration (both `.mcp.json` and `~/.claude/mcp_settings.json`), server health, and port status with actionable fix suggestions. You can also check the raw health endpoint:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
curl http://localhost:3479/health
|
|
66
|
+
# → {"status":"ok","version":"0.1.0","transport":"http","hasSession":false}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`hasSession` becomes `true` once Claude Code connects.
|
|
70
|
+
|
|
71
|
+
## MCP Configuration
|
|
72
|
+
|
|
73
|
+
Tandem uses two MCP connections: **HTTP** for document tools (27 tools including annotation editing), and a **channel shim** for real-time push notifications.
|
|
74
|
+
|
|
75
|
+
**Global install** (`tandem setup`): Automatically writes both entries to `~/.claude/mcp_settings.json` (Claude Code) and/or `claude_desktop_config.json` (Claude Desktop) with absolute paths. No manual configuration needed.
|
|
76
|
+
|
|
77
|
+
**Development setup** (`.mcp.json`): The repo includes a `.mcp.json` that configures both entries automatically when Claude Code runs from the repo directory:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"mcpServers": {
|
|
82
|
+
"tandem": {
|
|
83
|
+
"type": "http",
|
|
84
|
+
"url": "http://localhost:3479/mcp"
|
|
85
|
+
},
|
|
86
|
+
"tandem-channel": {
|
|
87
|
+
"command": "npx",
|
|
88
|
+
"args": ["tsx", "src/channel/index.ts"],
|
|
89
|
+
"env": { "TANDEM_URL": "http://localhost:3479" }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Both entries are cross-platform -- no platform-specific configuration needed.
|
|
96
|
+
|
|
97
|
+
The channel shim is optional -- without it, Claude relies on polling via `tandem_checkInbox` instead of receiving real-time push events.
|
|
98
|
+
|
|
99
|
+
**Important:** The server must be running before Claude Code connects.
|
|
100
|
+
|
|
101
|
+
## Environment Variables
|
|
102
|
+
|
|
103
|
+
All optional -- defaults work out of the box.
|
|
104
|
+
|
|
105
|
+
| Variable | Default | Description |
|
|
106
|
+
|----------|---------|-------------|
|
|
107
|
+
| `TANDEM_PORT` | `3478` | Hocuspocus WebSocket port |
|
|
108
|
+
| `TANDEM_MCP_PORT` | `3479` | MCP HTTP + REST API port |
|
|
109
|
+
| `TANDEM_URL` | `http://localhost:3479` | Channel shim server URL |
|
|
110
|
+
| `TANDEM_TRANSPORT` | `http` | Transport mode (`http` or `stdio`) |
|
|
111
|
+
| `TANDEM_NO_SAMPLE` | unset | Set to `1` to skip auto-opening `sample/welcome.md` |
|
|
112
|
+
|
|
113
|
+
See `.env.example` for a copy-paste template.
|
|
114
|
+
|
|
115
|
+
## Troubleshooting
|
|
116
|
+
|
|
117
|
+
Run `npm run doctor` for a quick diagnostic of your setup. It checks Node.js version, `.mcp.json` config, server health, and port status.
|
|
118
|
+
|
|
119
|
+
**Claude Code says "MCP failed to connect"**
|
|
120
|
+
Start the server first (`tandem` for global install, or `npm run dev:standalone` for dev setup), then open Claude Code. The server must be running before Claude Code probes the MCP URL. If you restart the server, run `/mcp` in Claude Code to reconnect.
|
|
121
|
+
|
|
122
|
+
**Port already in use**
|
|
123
|
+
Tandem kills stale processes on :3478/:3479 at startup. If another app uses those ports, set `TANDEM_PORT` / `TANDEM_MCP_PORT` to different values and update `TANDEM_URL` to match.
|
|
124
|
+
|
|
125
|
+
**Channel shim fails to start**
|
|
126
|
+
The `tandem-channel` entry spawns a subprocess. For global installs, `tandem setup` writes absolute paths to the bundled `dist/channel/index.js` -- re-run `tandem setup` after upgrading. For dev setup, if you see `MODULE_NOT_FOUND` with a production config (`node dist/channel/index.js`), run `npm run build`. The default dev config uses `npx tsx` and doesn't require a build step.
|
|
127
|
+
|
|
128
|
+
**Browser shows "Cannot reach the Tandem server"**
|
|
129
|
+
The browser connects to the server via WebSocket. For global installs, run `tandem` to start the server. For dev setup, use `npm run dev:standalone` (or `npm run dev:server`). The message appears after 3 seconds of failed connection.
|
|
130
|
+
|
|
131
|
+
**Empty browser with no document**
|
|
132
|
+
On first run, `sample/welcome.md` auto-opens. If you've cleared sessions or deleted the sample file, click the **+** button in the tab bar or drop a file onto the editor.
|
|
133
|
+
|
|
134
|
+
## Features
|
|
135
|
+
|
|
136
|
+
### Annotations
|
|
137
|
+
|
|
138
|
+
Claude adds highlights, comments, suggestions, and flags directly in the document. Each annotation type has distinct styling -- colored backgrounds for highlights, dashed underlines for comments, wavy underlines for suggestions -- so you can scan at a glance.
|
|
139
|
+
|
|
140
|
+

|
|
141
|
+
|
|
142
|
+
The side panel lists all annotations with filtering by type, author, and status. Each card shows a preview of the annotated text. Bulk accept/dismiss buttons appear when multiple annotations are pending. Annotations can be edited after creation via the pencil button -- typos in comments or suggestions no longer require deleting and recreating.
|
|
143
|
+
|
|
144
|
+
### Chat Sidebar
|
|
145
|
+
|
|
146
|
+

|
|
147
|
+
|
|
148
|
+
The right sidebar toggles between **Annotations** and **Chat** views. In chat mode, send freeform messages to Claude alongside annotation review. If you have text selected when you hit Send, it is attached as a clickable anchor -- clicking it later scrolls the editor back to that passage. Claude's responses are rendered as Markdown. An unread badge on the Chat tab appears when Claude has replied while you were in the Annotations view.
|
|
149
|
+
|
|
150
|
+
### Toolbar
|
|
151
|
+
|
|
152
|
+

|
|
153
|
+
|
|
154
|
+
Select text in the editor to activate the toolbar buttons: Highlight (with color picker), Comment, Suggest, Flag, and Ask Claude. The tab bar shows open documents with format indicators (M for Markdown, W for Word, T for Text). Tabs scroll horizontally when they overflow, and can be reordered via drag-and-drop or Alt+Left/Right.
|
|
155
|
+
|
|
156
|
+
### Keyboard Review Mode
|
|
157
|
+
|
|
158
|
+

|
|
159
|
+
|
|
160
|
+
Press **Ctrl+Shift+R** or click "Review in sequence" to enter review mode. The editor dims non-annotated text so annotations stand out. Navigate with **Tab/Shift+Tab**, accept with **Y**, dismiss with **N**, or examine with **E**. The side panel tracks your position (e.g. "Reviewing 1 / 7").
|
|
161
|
+
|
|
162
|
+
### Claude's Presence
|
|
163
|
+
|
|
164
|
+

|
|
165
|
+
|
|
166
|
+
The status bar shows real-time connection state with reconnect attempt count and elapsed time, open document count, and Claude's current activity. A prominent banner appears after 30 seconds of continuous disconnect with actionable guidance. Claude's focus paragraph gets a subtle blue highlight in the editor. Interruption modes (All / Urgent / Paused) control which annotations surface immediately vs. get held for later.
|
|
167
|
+
|
|
168
|
+
### Toast Notifications
|
|
169
|
+
|
|
170
|
+

|
|
171
|
+
|
|
172
|
+
Annotation failures and save errors surface as dismissible toast notifications. Toasts auto-dismiss by severity (errors linger longest) and deduplicate with a count badge when the same message repeats.
|
|
173
|
+
|
|
174
|
+
### Onboarding Tutorial
|
|
175
|
+
|
|
176
|
+

|
|
177
|
+
|
|
178
|
+
On first launch, a 3-step guided walkthrough appears over the welcome document. Pre-placed annotations let you practice reviewing, asking Claude a question, and editing -- then the tutorial dismisses itself. Progress persists in localStorage so it only shows once.
|
|
179
|
+
|
|
180
|
+
### More
|
|
181
|
+
|
|
182
|
+
- **Multi-document tabs** -- open `.md`, `.txt`, `.docx` files side by side, each in its own Y.Doc room
|
|
183
|
+
- **Browser file open** -- click "+" in the tab bar or drag-and-drop a file onto the editor (no Claude needed); recent files remembered
|
|
184
|
+
- **Markdown round-trip** -- lossless MDAST-based conversion preserves formatting through load/save cycles
|
|
185
|
+
- **.docx review-only mode** -- open Word documents for annotation; a banner makes clear edits aren't saved back to the original
|
|
186
|
+
- **Session persistence** -- Y.Doc state and annotations survive server restarts
|
|
187
|
+
- **Real-time channel push** -- annotation accepts/dismisses, chat messages, and document switches push to Claude instantly via the Channels API (no polling)
|
|
188
|
+
- **User→Claude inbox** -- highlights, comments, and questions you add are surfaced to Claude via push events or `tandem_checkInbox` fallback
|
|
189
|
+
- **Unsaved-changes indicator** -- dot on the tab title when a document has pending edits
|
|
190
|
+
- **Configurable display name** -- set your name so Claude knows who's reviewing
|
|
191
|
+
- **Annotation text preview** -- each card in the side panel shows an excerpt of the annotated text
|
|
192
|
+
- **Keyboard shortcuts reference** -- press `?` to open the in-app shortcut reference
|
|
193
|
+
- **E2E tested** -- Playwright tests cover the annotation lifecycle end-to-end
|
|
194
|
+
- **Atomic file saves** -- write to temp, then rename, preventing partial writes
|
|
195
|
+
|
|
196
|
+
## CLI Commands
|
|
197
|
+
|
|
198
|
+
| Command | What it does |
|
|
199
|
+
|---------|-------------|
|
|
200
|
+
| `tandem` | Start server and open browser (global install) |
|
|
201
|
+
| `tandem setup` | Register MCP tools with Claude Code / Claude Desktop |
|
|
202
|
+
| `tandem setup --force` | Register to default paths regardless of auto-detection |
|
|
203
|
+
| `tandem --version` | Show installed version |
|
|
204
|
+
| `tandem --help` | Show usage |
|
|
205
|
+
|
|
206
|
+
## Development Scripts
|
|
207
|
+
|
|
208
|
+
| Command | What it does |
|
|
209
|
+
|---------|-------------|
|
|
210
|
+
| `npm run dev:standalone` | **Recommended** -- both frontend + backend (via concurrently) |
|
|
211
|
+
| `npm run dev:server` | Backend only: Hocuspocus (:3478) + MCP HTTP (:3479) |
|
|
212
|
+
| `npm run dev:client` | Frontend only: Vite dev server (:5173) |
|
|
213
|
+
| `npm run build` | Production build (`dist/server/` + `dist/channel/` + `dist/cli/` + `dist/client/`) |
|
|
214
|
+
| `npm test` | Run vitest (unit tests) |
|
|
215
|
+
| `npm run test:e2e` | Run Playwright E2E tests |
|
|
216
|
+
| `npm run test:e2e:ui` | Playwright UI mode |
|
|
217
|
+
|
|
218
|
+
## Documentation
|
|
219
|
+
|
|
220
|
+
- [MCP Tool Reference](docs/mcp-tools.md) -- 27 MCP tools + channel API endpoints
|
|
221
|
+
- [Architecture](docs/architecture.md) -- System design, data flows, coordinate systems, channel push
|
|
222
|
+
- [Workflows](docs/workflows.md) -- Real-world usage patterns
|
|
223
|
+
- [Roadmap](docs/roadmap.md) -- Phase 2+ roadmap, known issues, future extensions
|
|
224
|
+
- [Design Decisions](docs/decisions.md) -- ADR-001 through ADR-021
|
|
225
|
+
- [Lessons Learned](docs/lessons-learned.md) -- 31 implementation lessons
|
|
226
|
+
|
|
227
|
+
## Tech Stack
|
|
228
|
+
|
|
229
|
+
**Frontend:** React 19, Tiptap, Vite, TypeScript
|
|
230
|
+
**Backend:** Node.js, Hocuspocus (Yjs WebSocket), MCP SDK (Streamable HTTP transport), Express
|
|
231
|
+
**Collaboration:** Yjs (CRDT), @hocuspocus/provider, y-prosemirror
|
|
232
|
+
**File I/O:** mammoth.js (.docx), unified/remark (.md)
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/channel/index.ts
|
|
4
|
+
import { createConnection } from "net";
|
|
5
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
// src/shared/constants.ts
|
|
11
|
+
var DEFAULT_MCP_PORT = 3479;
|
|
12
|
+
var MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
13
|
+
var MAX_WS_PAYLOAD = 10 * 1024 * 1024;
|
|
14
|
+
var IDLE_TIMEOUT = 30 * 60 * 1e3;
|
|
15
|
+
var SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
|
|
16
|
+
var CHANNEL_MAX_RETRIES = 5;
|
|
17
|
+
var CHANNEL_RETRY_DELAY_MS = 2e3;
|
|
18
|
+
|
|
19
|
+
// src/server/events/types.ts
|
|
20
|
+
var VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
21
|
+
"annotation:created",
|
|
22
|
+
"annotation:accepted",
|
|
23
|
+
"annotation:dismissed",
|
|
24
|
+
"chat:message",
|
|
25
|
+
"selection:changed",
|
|
26
|
+
"document:opened",
|
|
27
|
+
"document:closed",
|
|
28
|
+
"document:switched"
|
|
29
|
+
]);
|
|
30
|
+
function parseTandemEvent(raw) {
|
|
31
|
+
if (typeof raw !== "object" || raw === null || !("id" in raw) || typeof raw.id !== "string" || !("type" in raw) || !VALID_EVENT_TYPES.has(raw.type) || !("timestamp" in raw) || typeof raw.timestamp !== "number" || !("payload" in raw) || typeof raw.payload !== "object") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return raw;
|
|
35
|
+
}
|
|
36
|
+
function formatEventContent(event) {
|
|
37
|
+
const doc = event.documentId ? ` [doc: ${event.documentId}]` : "";
|
|
38
|
+
switch (event.type) {
|
|
39
|
+
case "annotation:created": {
|
|
40
|
+
const { annotationType, content, textSnippet } = event.payload;
|
|
41
|
+
const snippet = textSnippet ? ` on "${textSnippet}"` : "";
|
|
42
|
+
return `User created ${annotationType}${snippet}: ${content || "(no content)"}${doc}`;
|
|
43
|
+
}
|
|
44
|
+
case "annotation:accepted": {
|
|
45
|
+
const { annotationId, textSnippet } = event.payload;
|
|
46
|
+
return `User accepted annotation ${annotationId}${textSnippet ? ` ("${textSnippet}")` : ""}${doc}`;
|
|
47
|
+
}
|
|
48
|
+
case "annotation:dismissed": {
|
|
49
|
+
const { annotationId, textSnippet } = event.payload;
|
|
50
|
+
return `User dismissed annotation ${annotationId}${textSnippet ? ` ("${textSnippet}")` : ""}${doc}`;
|
|
51
|
+
}
|
|
52
|
+
case "chat:message": {
|
|
53
|
+
const { text, replyTo } = event.payload;
|
|
54
|
+
const reply = replyTo ? ` (replying to ${replyTo})` : "";
|
|
55
|
+
return `User says${reply}: ${text}${doc}`;
|
|
56
|
+
}
|
|
57
|
+
case "selection:changed": {
|
|
58
|
+
const { from, to, selectedText } = event.payload;
|
|
59
|
+
if (!selectedText) return `User cleared selection${doc}`;
|
|
60
|
+
return `User selected text (${from}-${to}): "${selectedText}"${doc}`;
|
|
61
|
+
}
|
|
62
|
+
case "document:opened": {
|
|
63
|
+
const { fileName, format } = event.payload;
|
|
64
|
+
return `User opened document: ${fileName} (${format})${doc}`;
|
|
65
|
+
}
|
|
66
|
+
case "document:closed": {
|
|
67
|
+
const { fileName } = event.payload;
|
|
68
|
+
return `User closed document: ${fileName}${doc}`;
|
|
69
|
+
}
|
|
70
|
+
case "document:switched": {
|
|
71
|
+
const { fileName } = event.payload;
|
|
72
|
+
return `User switched to document: ${fileName}${doc}`;
|
|
73
|
+
}
|
|
74
|
+
default: {
|
|
75
|
+
const _exhaustive = event;
|
|
76
|
+
return `Unknown event${doc}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function formatEventMeta(event) {
|
|
81
|
+
const meta = {
|
|
82
|
+
event_type: event.type
|
|
83
|
+
};
|
|
84
|
+
if (event.documentId) meta.document_id = event.documentId;
|
|
85
|
+
switch (event.type) {
|
|
86
|
+
case "annotation:created":
|
|
87
|
+
case "annotation:accepted":
|
|
88
|
+
case "annotation:dismissed":
|
|
89
|
+
meta.annotation_id = event.payload.annotationId;
|
|
90
|
+
break;
|
|
91
|
+
case "chat:message":
|
|
92
|
+
meta.message_id = event.payload.messageId;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
return meta;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/channel/event-bridge.ts
|
|
99
|
+
var AWARENESS_DEBOUNCE_MS = 500;
|
|
100
|
+
async function startEventBridge(mcp2, tandemUrl) {
|
|
101
|
+
let retries = 0;
|
|
102
|
+
let lastEventId;
|
|
103
|
+
while (retries < CHANNEL_MAX_RETRIES) {
|
|
104
|
+
try {
|
|
105
|
+
await connectAndStream(mcp2, tandemUrl, lastEventId, (id) => {
|
|
106
|
+
lastEventId = id;
|
|
107
|
+
retries = 0;
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
retries++;
|
|
111
|
+
console.error(
|
|
112
|
+
`[Channel] SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,
|
|
113
|
+
err instanceof Error ? err.message : err
|
|
114
|
+
);
|
|
115
|
+
if (retries >= CHANNEL_MAX_RETRIES) {
|
|
116
|
+
console.error("[Channel] SSE connection exhausted, reporting error and exiting");
|
|
117
|
+
try {
|
|
118
|
+
await fetch(`${tandemUrl}/api/channel-error`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
body: JSON.stringify({
|
|
122
|
+
error: "CHANNEL_CONNECT_FAILED",
|
|
123
|
+
message: `Channel shim lost connection after ${CHANNEL_MAX_RETRIES} retries.`
|
|
124
|
+
})
|
|
125
|
+
});
|
|
126
|
+
} catch (reportErr) {
|
|
127
|
+
console.error(
|
|
128
|
+
"[Channel] Could not report failure to server:",
|
|
129
|
+
reportErr instanceof Error ? reportErr.message : reportErr
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
await new Promise((r) => setTimeout(r, CHANNEL_RETRY_DELAY_MS));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function connectAndStream(mcp2, tandemUrl, lastEventId, onEventId) {
|
|
139
|
+
const headers = { Accept: "text/event-stream" };
|
|
140
|
+
if (lastEventId) headers["Last-Event-ID"] = lastEventId;
|
|
141
|
+
const res = await fetch(`${tandemUrl}/api/events`, { headers });
|
|
142
|
+
if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
|
|
143
|
+
if (!res.body) throw new Error("SSE endpoint returned no body");
|
|
144
|
+
const reader = res.body.getReader();
|
|
145
|
+
const decoder = new TextDecoder();
|
|
146
|
+
let buffer = "";
|
|
147
|
+
let awarenessTimer = null;
|
|
148
|
+
let pendingAwareness = null;
|
|
149
|
+
function flushAwareness() {
|
|
150
|
+
if (!pendingAwareness) return;
|
|
151
|
+
const event = pendingAwareness;
|
|
152
|
+
pendingAwareness = null;
|
|
153
|
+
fetch(`${tandemUrl}/api/channel-awareness`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "Content-Type": "application/json" },
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
documentId: event.documentId,
|
|
158
|
+
status: `processing: ${event.type}`,
|
|
159
|
+
active: true
|
|
160
|
+
})
|
|
161
|
+
}).catch((err) => {
|
|
162
|
+
console.error("[Channel] Awareness update failed:", err instanceof Error ? err.message : err);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function scheduleAwareness(event) {
|
|
166
|
+
pendingAwareness = event;
|
|
167
|
+
if (awarenessTimer) clearTimeout(awarenessTimer);
|
|
168
|
+
awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
|
|
169
|
+
}
|
|
170
|
+
while (true) {
|
|
171
|
+
const { done, value } = await reader.read();
|
|
172
|
+
if (done) throw new Error("SSE stream ended");
|
|
173
|
+
buffer += decoder.decode(value, { stream: true });
|
|
174
|
+
let boundary;
|
|
175
|
+
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
|
176
|
+
const frame = buffer.slice(0, boundary);
|
|
177
|
+
buffer = buffer.slice(boundary + 2);
|
|
178
|
+
if (frame.startsWith(":")) continue;
|
|
179
|
+
let eventId;
|
|
180
|
+
let data;
|
|
181
|
+
for (const line of frame.split("\n")) {
|
|
182
|
+
if (line.startsWith("id: ")) eventId = line.slice(4);
|
|
183
|
+
else if (line.startsWith("data: ")) data = line.slice(6);
|
|
184
|
+
}
|
|
185
|
+
if (!data) continue;
|
|
186
|
+
let event;
|
|
187
|
+
try {
|
|
188
|
+
event = parseTandemEvent(JSON.parse(data));
|
|
189
|
+
} catch {
|
|
190
|
+
console.error("[Channel] Malformed SSE event data (skipping):", data.slice(0, 200));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!event) {
|
|
194
|
+
console.error("[Channel] Received invalid SSE event, skipping");
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (eventId) onEventId(eventId);
|
|
198
|
+
try {
|
|
199
|
+
await mcp2.notification({
|
|
200
|
+
method: "notifications/claude/channel",
|
|
201
|
+
params: {
|
|
202
|
+
content: formatEventContent(event),
|
|
203
|
+
meta: formatEventMeta(event)
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
} catch (err) {
|
|
207
|
+
console.error("[Channel] MCP notification failed (transport broken?):", err);
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
scheduleAwareness(event);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/channel/index.ts
|
|
216
|
+
console.log = console.error;
|
|
217
|
+
console.warn = console.error;
|
|
218
|
+
console.info = console.error;
|
|
219
|
+
var TANDEM_URL = process.env.TANDEM_URL || "http://localhost:3479";
|
|
220
|
+
async function checkServerReachable(url, timeoutMs = 2e3) {
|
|
221
|
+
let parsed;
|
|
222
|
+
try {
|
|
223
|
+
parsed = new URL(url);
|
|
224
|
+
} catch {
|
|
225
|
+
console.error(
|
|
226
|
+
`[Channel] Invalid TANDEM_URL: "${url}" \u2014 expected format: http://localhost:3479`
|
|
227
|
+
);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
const port = parseInt(parsed.port || String(DEFAULT_MCP_PORT), 10);
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
const socket = createConnection({ port, host: parsed.hostname }, () => {
|
|
233
|
+
socket.destroy();
|
|
234
|
+
resolve(true);
|
|
235
|
+
});
|
|
236
|
+
socket.setTimeout(timeoutMs);
|
|
237
|
+
socket.on("timeout", () => {
|
|
238
|
+
socket.destroy();
|
|
239
|
+
resolve(false);
|
|
240
|
+
});
|
|
241
|
+
socket.on("error", (err) => {
|
|
242
|
+
console.error(`[Channel] Server probe failed: ${err.message}`);
|
|
243
|
+
socket.destroy();
|
|
244
|
+
resolve(false);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
var mcp = new Server(
|
|
249
|
+
{ name: "tandem-channel", version: "0.1.0" },
|
|
250
|
+
{
|
|
251
|
+
capabilities: {
|
|
252
|
+
experimental: {
|
|
253
|
+
"claude/channel": {},
|
|
254
|
+
"claude/channel/permission": {}
|
|
255
|
+
},
|
|
256
|
+
tools: {}
|
|
257
|
+
},
|
|
258
|
+
instructions: [
|
|
259
|
+
'Events from Tandem arrive as <channel source="tandem-channel" event_type="..." document_id="...">.',
|
|
260
|
+
"These are real-time push notifications of user actions in the collaborative document editor.",
|
|
261
|
+
"Event types: annotation:created, annotation:accepted, annotation:dismissed,",
|
|
262
|
+
"chat:message, selection:changed, document:opened, document:closed, document:switched.",
|
|
263
|
+
"Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.",
|
|
264
|
+
"Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
|
|
265
|
+
"Do not reply to non-chat events \u2014 just act on them using tools.",
|
|
266
|
+
"If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
|
|
267
|
+
].join(" ")
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
271
|
+
tools: [
|
|
272
|
+
{
|
|
273
|
+
name: "tandem_reply",
|
|
274
|
+
description: "Reply to a chat message in Tandem",
|
|
275
|
+
inputSchema: {
|
|
276
|
+
type: "object",
|
|
277
|
+
properties: {
|
|
278
|
+
text: { type: "string", description: "The reply message" },
|
|
279
|
+
documentId: {
|
|
280
|
+
type: "string",
|
|
281
|
+
description: "Document ID from the channel event (optional)"
|
|
282
|
+
},
|
|
283
|
+
replyTo: {
|
|
284
|
+
type: "string",
|
|
285
|
+
description: "Message ID being replied to (optional)"
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
required: ["text"]
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
]
|
|
292
|
+
}));
|
|
293
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
294
|
+
if (req.params.name === "tandem_reply") {
|
|
295
|
+
const args = req.params.arguments;
|
|
296
|
+
try {
|
|
297
|
+
const res = await fetch(`${TANDEM_URL}/api/channel-reply`, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: { "Content-Type": "application/json" },
|
|
300
|
+
body: JSON.stringify(args)
|
|
301
|
+
});
|
|
302
|
+
let data;
|
|
303
|
+
try {
|
|
304
|
+
data = await res.json();
|
|
305
|
+
} catch {
|
|
306
|
+
data = { message: "Non-JSON response" };
|
|
307
|
+
}
|
|
308
|
+
if (!res.ok) {
|
|
309
|
+
return {
|
|
310
|
+
content: [
|
|
311
|
+
{
|
|
312
|
+
type: "text",
|
|
313
|
+
text: `Reply failed (${res.status}): ${JSON.stringify(data)}`
|
|
314
|
+
}
|
|
315
|
+
],
|
|
316
|
+
isError: true
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
320
|
+
} catch (err) {
|
|
321
|
+
return {
|
|
322
|
+
content: [
|
|
323
|
+
{
|
|
324
|
+
type: "text",
|
|
325
|
+
text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
|
|
326
|
+
}
|
|
327
|
+
],
|
|
328
|
+
isError: true
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
throw new Error(`Unknown tool: ${req.params.name}`);
|
|
333
|
+
});
|
|
334
|
+
var PermissionRequestSchema = z.object({
|
|
335
|
+
method: z.literal("notifications/claude/channel/permission_request"),
|
|
336
|
+
params: z.object({
|
|
337
|
+
request_id: z.string(),
|
|
338
|
+
tool_name: z.string(),
|
|
339
|
+
description: z.string(),
|
|
340
|
+
input_preview: z.string()
|
|
341
|
+
})
|
|
342
|
+
});
|
|
343
|
+
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
|
|
344
|
+
try {
|
|
345
|
+
const res = await fetch(`${TANDEM_URL}/api/channel-permission`, {
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: { "Content-Type": "application/json" },
|
|
348
|
+
body: JSON.stringify({
|
|
349
|
+
requestId: params.request_id,
|
|
350
|
+
toolName: params.tool_name,
|
|
351
|
+
description: params.description,
|
|
352
|
+
inputPreview: params.input_preview
|
|
353
|
+
})
|
|
354
|
+
});
|
|
355
|
+
if (!res.ok) {
|
|
356
|
+
console.error(
|
|
357
|
+
`[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error("[Channel] Failed to forward permission request:", err);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
async function main() {
|
|
365
|
+
console.error(`[Channel] Tandem channel shim starting (server: ${TANDEM_URL})`);
|
|
366
|
+
const reachable = await checkServerReachable(TANDEM_URL);
|
|
367
|
+
if (!reachable) {
|
|
368
|
+
console.error(`[Channel] Cannot reach Tandem server at ${TANDEM_URL}`);
|
|
369
|
+
console.error("[Channel] Start it with: npm run dev:standalone");
|
|
370
|
+
}
|
|
371
|
+
const transport = new StdioServerTransport();
|
|
372
|
+
await mcp.connect(transport);
|
|
373
|
+
console.error("[Channel] Connected to Claude Code via stdio");
|
|
374
|
+
startEventBridge(mcp, TANDEM_URL).catch((err) => {
|
|
375
|
+
console.error("[Channel] Event bridge failed unexpectedly:", err);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
main().catch((err) => {
|
|
380
|
+
console.error("[Channel] Fatal error:", err);
|
|
381
|
+
process.exit(1);
|
|
382
|
+
});
|
|
383
|
+
//# sourceMappingURL=index.js.map
|