tandem-editor 0.2.11 → 0.2.12
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/CHANGELOG.md +130 -117
- package/README.md +201 -201
- package/dist/channel/index.js +0 -0
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/{index-CfGlbY9B.js → index-R-RaIO5I.js} +54 -54
- package/dist/client/index.html +13 -13
- package/dist/server/index.js.map +1 -1
- package/package.json +5 -1
- package/sample/demo-script.md +23 -23
- package/sample/welcome.md +21 -21
package/README.md
CHANGED
|
@@ -1,201 +1,201 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="docs/assets/banner.png" alt="Tandem — Collaborative AI-Human Document Editor" width="800">
|
|
3
|
-
</p>
|
|
4
|
-
|
|
5
|
-
An AI document reviewer — open a progress report, RFP, or compliance filing and Claude reviews it alongside you in real time. Highlights, comments, suggestions, and questions appear as first-class annotations you accept, dismiss, or discuss. The original file is never modified unless you save.
|
|
6
|
-
|
|
7
|
-

|
|
8
|
-
|
|
9
|
-
## Quick Start
|
|
10
|
-
|
|
11
|
-
### Prerequisites
|
|
12
|
-
|
|
13
|
-
- **Node.js 22+** ([download](https://nodejs.org))
|
|
14
|
-
- **Claude Code** (`irm https://claude.ai/install.ps1 | iex`)
|
|
15
|
-
|
|
16
|
-
### Install and Run
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
npm install -g tandem-editor
|
|
20
|
-
tandem setup # registers MCP tools + installs Claude Code skill
|
|
21
|
-
tandem # starts server + opens browser
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
`tandem setup` auto-detects Claude Code and Claude Desktop, writes MCP configuration, and installs a skill (`~/.claude/skills/tandem/SKILL.md`) that teaches Claude how to use Tandem's tools effectively. Re-run after upgrading (`npm update -g tandem-editor && tandem setup`).
|
|
25
|
-
|
|
26
|
-
### Connect Claude Code
|
|
27
|
-
|
|
28
|
-
Start Claude Code with channel push for real-time notifications:
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
claude --dangerously-load-development-channels server:tandem-channel
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
Then try:
|
|
35
|
-
|
|
36
|
-
```
|
|
37
|
-
"Review the welcome document with me"
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Claude calls `tandem_open`, the document appears in the browser, and annotations start flowing. Chat messages, annotation actions, and text selections push to Claude instantly.
|
|
41
|
-
|
|
42
|
-
**Without channels:** Use the `/loop` skill in Claude Code to poll:
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
/loop 30s check tandem inbox and respond to any new messages
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
### Verify
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
npm run doctor # checks Node.js, MCP config, server health, ports
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Or check the raw health endpoint:
|
|
55
|
-
|
|
56
|
-
```bash
|
|
57
|
-
curl http://localhost:3479/health
|
|
58
|
-
# → {"status":"ok","version":"0.1.2","transport":"http","hasSession":false}
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
`hasSession` becomes `true` once Claude Code connects.
|
|
62
|
-
|
|
63
|
-
<details>
|
|
64
|
-
<summary><strong>Development Setup</strong> (contributing / building from source)</summary>
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
git clone https://github.com/bloknayrb/tandem.git
|
|
68
|
-
cd tandem
|
|
69
|
-
npm install
|
|
70
|
-
npm run dev:standalone # starts server (:3478/:3479) + browser client (:5173)
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
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.
|
|
74
|
-
|
|
75
|
-
</details>
|
|
76
|
-
|
|
77
|
-
## Features
|
|
78
|
-
|
|
79
|
-
### Annotations
|
|
80
|
-
|
|
81
|
-

|
|
82
|
-
|
|
83
|
-
Claude adds highlights, comments, suggestions, and flags directly in the document. The side panel lists all annotations with filtering by type, author, and status. Accept, dismiss, or edit each one individually — or use bulk actions to process them in batches.
|
|
84
|
-
|
|
85
|
-
### Chat
|
|
86
|
-
|
|
87
|
-

|
|
88
|
-
|
|
89
|
-
Send freeform messages to Claude alongside annotation review. Select text before sending to attach it as a clickable anchor — clicking it later scrolls back to that passage.
|
|
90
|
-
|
|
91
|
-
### Review Mode
|
|
92
|
-
|
|
93
|
-

|
|
94
|
-
|
|
95
|
-
Press **Ctrl+Shift+R** to enter keyboard review mode. Navigate with **Tab**, accept with **Y**, dismiss with **N**, examine with **E**. A 10-second undo window lets you reverse accidental accepts. The side panel tracks your position.
|
|
96
|
-
|
|
97
|
-
### More
|
|
98
|
-
|
|
99
|
-
- **Multi-document tabs** — open `.md`, `.txt`, `.docx` files side by side; drag to reorder
|
|
100
|
-
- **.docx review-only mode** — open Word documents for annotation; imported Word comments appear alongside Claude's
|
|
101
|
-
- **Session persistence** — documents and annotations survive server restarts
|
|
102
|
-
- **Real-time channel push** — annotation actions, chat, and selections push to Claude instantly
|
|
103
|
-
- **Keyboard shortcuts** — press `?` for the full reference
|
|
104
|
-
- **Unsaved-changes indicator** — dot on tab title when a document has pending edits
|
|
105
|
-
- **Configurable display name** — set your name so Claude knows who's reviewing
|
|
106
|
-
- **Atomic file saves** — write to temp, then rename, preventing partial writes
|
|
107
|
-
- **E2E tested** — Playwright tests cover the annotation lifecycle end-to-end
|
|
108
|
-
|
|
109
|
-
## Documentation
|
|
110
|
-
|
|
111
|
-
- **[User Guide](docs/user-guide.md)** — How to use Tandem: browser UI, annotations, chat, review mode, keyboard shortcuts
|
|
112
|
-
- [MCP Tool Reference](docs/mcp-tools.md) — 30 MCP tools + channel API endpoints
|
|
113
|
-
- [Architecture](docs/architecture.md) — System design, data flows, coordinate systems, channel push
|
|
114
|
-
- [Workflows](docs/workflows.md) — Claude Code usage patterns: document review, cross-referencing, multi-model
|
|
115
|
-
- [Roadmap](docs/roadmap.md) — Phase 2+ roadmap, known issues, future extensions
|
|
116
|
-
- [Design Decisions](docs/decisions.md) — ADR-001 through ADR-021
|
|
117
|
-
- [Lessons Learned](docs/lessons-learned.md) — 31 implementation lessons
|
|
118
|
-
|
|
119
|
-
## CLI Commands
|
|
120
|
-
|
|
121
|
-
| Command | What it does |
|
|
122
|
-
|---------|-------------|
|
|
123
|
-
| `tandem` | Start server and open browser (global install) |
|
|
124
|
-
| `tandem setup` | Register MCP tools with Claude Code / Claude Desktop |
|
|
125
|
-
| `tandem setup --force` | Register to default paths regardless of auto-detection |
|
|
126
|
-
| `tandem --version` | Show installed version |
|
|
127
|
-
| `tandem --help` | Show usage |
|
|
128
|
-
|
|
129
|
-
## MCP Configuration
|
|
130
|
-
|
|
131
|
-
Tandem uses two MCP connections: **HTTP** for document tools (28 tools including annotation editing), and a **channel shim** for real-time push notifications.
|
|
132
|
-
|
|
133
|
-
**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.
|
|
134
|
-
|
|
135
|
-
**Development setup** (`.mcp.json`): The repo includes a `.mcp.json` that configures both entries automatically when Claude Code runs from the repo directory:
|
|
136
|
-
|
|
137
|
-
```json
|
|
138
|
-
{
|
|
139
|
-
"mcpServers": {
|
|
140
|
-
"tandem": {
|
|
141
|
-
"type": "http",
|
|
142
|
-
"url": "http://localhost:3479/mcp"
|
|
143
|
-
},
|
|
144
|
-
"tandem-channel": {
|
|
145
|
-
"command": "npx",
|
|
146
|
-
"args": ["tsx", "src/channel/index.ts"],
|
|
147
|
-
"env": { "TANDEM_URL": "http://localhost:3479" }
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Both entries are cross-platform — no platform-specific configuration needed.
|
|
154
|
-
|
|
155
|
-
## Environment Variables
|
|
156
|
-
|
|
157
|
-
All optional — defaults work out of the box.
|
|
158
|
-
|
|
159
|
-
| Variable | Default | Description |
|
|
160
|
-
|----------|---------|-------------|
|
|
161
|
-
| `TANDEM_PORT` | `3478` | Hocuspocus WebSocket port |
|
|
162
|
-
| `TANDEM_MCP_PORT` | `3479` | MCP HTTP + REST API port |
|
|
163
|
-
| `TANDEM_URL` | `http://localhost:3479` | Channel shim server URL |
|
|
164
|
-
| `TANDEM_TRANSPORT` | `http` | Transport mode (`http` or `stdio`) |
|
|
165
|
-
| `TANDEM_NO_SAMPLE` | unset | Set to `1` to skip auto-opening `sample/welcome.md` |
|
|
166
|
-
| `TANDEM_CLAUDE_CMD` | `claude` | Claude Code executable name (for `tandem setup` auto-detection) |
|
|
167
|
-
|
|
168
|
-
See `.env.example` for a copy-paste template.
|
|
169
|
-
|
|
170
|
-
## Troubleshooting
|
|
171
|
-
|
|
172
|
-
Run `npm run doctor` for a quick diagnostic of your setup. It checks Node.js version, `.mcp.json` config, server health, and port status.
|
|
173
|
-
|
|
174
|
-
**Claude Code says "MCP failed to connect"**
|
|
175
|
-
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.
|
|
176
|
-
|
|
177
|
-
**Port already in use**
|
|
178
|
-
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.
|
|
179
|
-
|
|
180
|
-
**Channel shim fails to start**
|
|
181
|
-
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.
|
|
182
|
-
|
|
183
|
-
**Browser shows "Cannot reach the Tandem server"**
|
|
184
|
-
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.
|
|
185
|
-
|
|
186
|
-
**Empty browser with no document**
|
|
187
|
-
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.
|
|
188
|
-
|
|
189
|
-
## Development
|
|
190
|
-
|
|
191
|
-
| Command | What it does |
|
|
192
|
-
|---------|-------------|
|
|
193
|
-
| `npm run dev:standalone` | **Recommended** — both frontend + backend (via concurrently) |
|
|
194
|
-
| `npm run dev:server` | Backend only: Hocuspocus (:3478) + MCP HTTP (:3479) |
|
|
195
|
-
| `npm run dev:client` | Frontend only: Vite dev server (:5173) |
|
|
196
|
-
| `npm run build` | Production build (`dist/server/` + `dist/channel/` + `dist/cli/` + `dist/client/`) |
|
|
197
|
-
| `npm test` | Run vitest (unit tests) |
|
|
198
|
-
| `npm run test:e2e` | Run Playwright E2E tests |
|
|
199
|
-
| `npm run test:e2e:ui` | Playwright UI mode |
|
|
200
|
-
|
|
201
|
-
**Tech Stack:** React 19, Tiptap, Vite, TypeScript | Node.js, Hocuspocus (Yjs WebSocket), MCP SDK, Express | Yjs (CRDT), y-prosemirror | mammoth.js (.docx), unified/remark (.md)
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/assets/banner.png" alt="Tandem — Collaborative AI-Human Document Editor" width="800">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
An AI document reviewer — open a progress report, RFP, or compliance filing and Claude reviews it alongside you in real time. Highlights, comments, suggestions, and questions appear as first-class annotations you accept, dismiss, or discuss. The original file is never modified unless you save.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
### Prerequisites
|
|
12
|
+
|
|
13
|
+
- **Node.js 22+** ([download](https://nodejs.org))
|
|
14
|
+
- **Claude Code** (`irm https://claude.ai/install.ps1 | iex`)
|
|
15
|
+
|
|
16
|
+
### Install and Run
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g tandem-editor
|
|
20
|
+
tandem setup # registers MCP tools + installs Claude Code skill
|
|
21
|
+
tandem # starts server + opens browser
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`tandem setup` auto-detects Claude Code and Claude Desktop, writes MCP configuration, and installs a skill (`~/.claude/skills/tandem/SKILL.md`) that teaches Claude how to use Tandem's tools effectively. Re-run after upgrading (`npm update -g tandem-editor && tandem setup`).
|
|
25
|
+
|
|
26
|
+
### Connect Claude Code
|
|
27
|
+
|
|
28
|
+
Start Claude Code with channel push for real-time notifications:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
claude --dangerously-load-development-channels server:tandem-channel
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then try:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
"Review the welcome document with me"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Claude calls `tandem_open`, the document appears in the browser, and annotations start flowing. Chat messages, annotation actions, and text selections push to Claude instantly.
|
|
41
|
+
|
|
42
|
+
**Without channels:** Use the `/loop` skill in Claude Code to poll:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
/loop 30s check tandem inbox and respond to any new messages
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Verify
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm run doctor # checks Node.js, MCP config, server health, ports
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or check the raw health endpoint:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
curl http://localhost:3479/health
|
|
58
|
+
# → {"status":"ok","version":"0.1.2","transport":"http","hasSession":false}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`hasSession` becomes `true` once Claude Code connects.
|
|
62
|
+
|
|
63
|
+
<details>
|
|
64
|
+
<summary><strong>Development Setup</strong> (contributing / building from source)</summary>
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/bloknayrb/tandem.git
|
|
68
|
+
cd tandem
|
|
69
|
+
npm install
|
|
70
|
+
npm run dev:standalone # starts server (:3478/:3479) + browser client (:5173)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
</details>
|
|
76
|
+
|
|
77
|
+
## Features
|
|
78
|
+
|
|
79
|
+
### Annotations
|
|
80
|
+
|
|
81
|
+

|
|
82
|
+
|
|
83
|
+
Claude adds highlights, comments, suggestions, and flags directly in the document. The side panel lists all annotations with filtering by type, author, and status. Accept, dismiss, or edit each one individually — or use bulk actions to process them in batches.
|
|
84
|
+
|
|
85
|
+
### Chat
|
|
86
|
+
|
|
87
|
+

|
|
88
|
+
|
|
89
|
+
Send freeform messages to Claude alongside annotation review. Select text before sending to attach it as a clickable anchor — clicking it later scrolls back to that passage.
|
|
90
|
+
|
|
91
|
+
### Review Mode
|
|
92
|
+
|
|
93
|
+

|
|
94
|
+
|
|
95
|
+
Press **Ctrl+Shift+R** to enter keyboard review mode. Navigate with **Tab**, accept with **Y**, dismiss with **N**, examine with **E**. A 10-second undo window lets you reverse accidental accepts. The side panel tracks your position.
|
|
96
|
+
|
|
97
|
+
### More
|
|
98
|
+
|
|
99
|
+
- **Multi-document tabs** — open `.md`, `.txt`, `.docx` files side by side; drag to reorder
|
|
100
|
+
- **.docx review-only mode** — open Word documents for annotation; imported Word comments appear alongside Claude's
|
|
101
|
+
- **Session persistence** — documents and annotations survive server restarts
|
|
102
|
+
- **Real-time channel push** — annotation actions, chat, and selections push to Claude instantly
|
|
103
|
+
- **Keyboard shortcuts** — press `?` for the full reference
|
|
104
|
+
- **Unsaved-changes indicator** — dot on tab title when a document has pending edits
|
|
105
|
+
- **Configurable display name** — set your name so Claude knows who's reviewing
|
|
106
|
+
- **Atomic file saves** — write to temp, then rename, preventing partial writes
|
|
107
|
+
- **E2E tested** — Playwright tests cover the annotation lifecycle end-to-end
|
|
108
|
+
|
|
109
|
+
## Documentation
|
|
110
|
+
|
|
111
|
+
- **[User Guide](docs/user-guide.md)** — How to use Tandem: browser UI, annotations, chat, review mode, keyboard shortcuts
|
|
112
|
+
- [MCP Tool Reference](docs/mcp-tools.md) — 30 MCP tools + channel API endpoints
|
|
113
|
+
- [Architecture](docs/architecture.md) — System design, data flows, coordinate systems, channel push
|
|
114
|
+
- [Workflows](docs/workflows.md) — Claude Code usage patterns: document review, cross-referencing, multi-model
|
|
115
|
+
- [Roadmap](docs/roadmap.md) — Phase 2+ roadmap, known issues, future extensions
|
|
116
|
+
- [Design Decisions](docs/decisions.md) — ADR-001 through ADR-021
|
|
117
|
+
- [Lessons Learned](docs/lessons-learned.md) — 31 implementation lessons
|
|
118
|
+
|
|
119
|
+
## CLI Commands
|
|
120
|
+
|
|
121
|
+
| Command | What it does |
|
|
122
|
+
|---------|-------------|
|
|
123
|
+
| `tandem` | Start server and open browser (global install) |
|
|
124
|
+
| `tandem setup` | Register MCP tools with Claude Code / Claude Desktop |
|
|
125
|
+
| `tandem setup --force` | Register to default paths regardless of auto-detection |
|
|
126
|
+
| `tandem --version` | Show installed version |
|
|
127
|
+
| `tandem --help` | Show usage |
|
|
128
|
+
|
|
129
|
+
## MCP Configuration
|
|
130
|
+
|
|
131
|
+
Tandem uses two MCP connections: **HTTP** for document tools (28 tools including annotation editing), and a **channel shim** for real-time push notifications.
|
|
132
|
+
|
|
133
|
+
**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.
|
|
134
|
+
|
|
135
|
+
**Development setup** (`.mcp.json`): The repo includes a `.mcp.json` that configures both entries automatically when Claude Code runs from the repo directory:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"mcpServers": {
|
|
140
|
+
"tandem": {
|
|
141
|
+
"type": "http",
|
|
142
|
+
"url": "http://localhost:3479/mcp"
|
|
143
|
+
},
|
|
144
|
+
"tandem-channel": {
|
|
145
|
+
"command": "npx",
|
|
146
|
+
"args": ["tsx", "src/channel/index.ts"],
|
|
147
|
+
"env": { "TANDEM_URL": "http://localhost:3479" }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Both entries are cross-platform — no platform-specific configuration needed.
|
|
154
|
+
|
|
155
|
+
## Environment Variables
|
|
156
|
+
|
|
157
|
+
All optional — defaults work out of the box.
|
|
158
|
+
|
|
159
|
+
| Variable | Default | Description |
|
|
160
|
+
|----------|---------|-------------|
|
|
161
|
+
| `TANDEM_PORT` | `3478` | Hocuspocus WebSocket port |
|
|
162
|
+
| `TANDEM_MCP_PORT` | `3479` | MCP HTTP + REST API port |
|
|
163
|
+
| `TANDEM_URL` | `http://localhost:3479` | Channel shim server URL |
|
|
164
|
+
| `TANDEM_TRANSPORT` | `http` | Transport mode (`http` or `stdio`) |
|
|
165
|
+
| `TANDEM_NO_SAMPLE` | unset | Set to `1` to skip auto-opening `sample/welcome.md` |
|
|
166
|
+
| `TANDEM_CLAUDE_CMD` | `claude` | Claude Code executable name (for `tandem setup` auto-detection) |
|
|
167
|
+
|
|
168
|
+
See `.env.example` for a copy-paste template.
|
|
169
|
+
|
|
170
|
+
## Troubleshooting
|
|
171
|
+
|
|
172
|
+
Run `npm run doctor` for a quick diagnostic of your setup. It checks Node.js version, `.mcp.json` config, server health, and port status.
|
|
173
|
+
|
|
174
|
+
**Claude Code says "MCP failed to connect"**
|
|
175
|
+
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.
|
|
176
|
+
|
|
177
|
+
**Port already in use**
|
|
178
|
+
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.
|
|
179
|
+
|
|
180
|
+
**Channel shim fails to start**
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
**Browser shows "Cannot reach the Tandem server"**
|
|
184
|
+
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.
|
|
185
|
+
|
|
186
|
+
**Empty browser with no document**
|
|
187
|
+
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.
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
| Command | What it does |
|
|
192
|
+
|---------|-------------|
|
|
193
|
+
| `npm run dev:standalone` | **Recommended** — both frontend + backend (via concurrently) |
|
|
194
|
+
| `npm run dev:server` | Backend only: Hocuspocus (:3478) + MCP HTTP (:3479) |
|
|
195
|
+
| `npm run dev:client` | Frontend only: Vite dev server (:5173) |
|
|
196
|
+
| `npm run build` | Production build (`dist/server/` + `dist/channel/` + `dist/cli/` + `dist/client/`) |
|
|
197
|
+
| `npm test` | Run vitest (unit tests) |
|
|
198
|
+
| `npm run test:e2e` | Run Playwright E2E tests |
|
|
199
|
+
| `npm run test:e2e:ui` | Playwright UI mode |
|
|
200
|
+
|
|
201
|
+
**Tech Stack:** React 19, Tiptap, Vite, TypeScript | Node.js, Hocuspocus (Yjs WebSocket), MCP SDK, Express | Yjs (CRDT), y-prosemirror | mammoth.js (.docx), unified/remark (.md)
|
package/dist/channel/index.js
CHANGED
|
File without changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/channel/index.ts","../../src/shared/constants.ts","../../src/server/events/types.ts","../../src/channel/event-bridge.ts"],"sourcesContent":["#!/usr/bin/env node\r\n/**\r\n * Tandem Channel Shim — Claude Code spawns this as a subprocess.\r\n *\r\n * Bridges Tandem's SSE event stream → Claude Code channel notifications,\r\n * and exposes a `tandem_reply` tool for Claude to respond to chat messages.\r\n *\r\n * Uses the low-level MCP `Server` class (not `McpServer`) as required by\r\n * the Channels API spec.\r\n */\r\n\r\nimport { createConnection } from \"node:net\";\r\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\r\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\r\nimport { CallToolRequestSchema, ListToolsRequestSchema } from \"@modelcontextprotocol/sdk/types.js\";\r\nimport { z } from \"zod\";\r\nimport { DEFAULT_MCP_PORT } from \"../shared/constants.js\";\r\nimport { startEventBridge } from \"./event-bridge.js\";\r\n\r\n// stdout is the MCP wire — redirect console.log to stderr\r\nconsole.log = console.error;\r\nconsole.warn = console.error;\r\nconsole.info = console.error;\r\n\r\nconst TANDEM_URL = process.env.TANDEM_URL || \"http://localhost:3479\";\r\n\r\n// --- Pre-flight: verify Tandem server is reachable before MCP handshake ---\r\n\r\nasync function checkServerReachable(url: string, timeoutMs = 2000): Promise<boolean> {\r\n let parsed: URL;\r\n try {\r\n parsed = new URL(url);\r\n } catch {\r\n console.error(\r\n `[Channel] Invalid TANDEM_URL: \"${url}\" — expected format: http://localhost:3479`,\r\n );\r\n return false;\r\n }\r\n const port = parseInt(parsed.port || String(DEFAULT_MCP_PORT), 10);\r\n return new Promise((resolve) => {\r\n const socket = createConnection({ port, host: parsed.hostname }, () => {\r\n socket.destroy();\r\n resolve(true);\r\n });\r\n socket.setTimeout(timeoutMs);\r\n socket.on(\"timeout\", () => {\r\n socket.destroy();\r\n resolve(false);\r\n });\r\n socket.on(\"error\", (err) => {\r\n console.error(`[Channel] Server probe failed: ${err.message}`);\r\n socket.destroy();\r\n resolve(false);\r\n });\r\n });\r\n}\r\n\r\n// --- MCP Server setup ---\r\n\r\nconst mcp = new Server(\r\n { name: \"tandem-channel\", version: \"0.1.0\" },\r\n {\r\n capabilities: {\r\n experimental: {\r\n \"claude/channel\": {},\r\n \"claude/channel/permission\": {},\r\n },\r\n tools: {},\r\n },\r\n instructions: [\r\n 'Events from Tandem arrive as <channel source=\"tandem-channel\" event_type=\"...\" document_id=\"...\">.',\r\n \"These are real-time push notifications of user actions in the collaborative document editor.\",\r\n \"Event types: annotation:created, annotation:accepted, annotation:dismissed,\",\r\n \"chat:message, selection:changed, document:opened, document:closed, document:switched.\",\r\n \"Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.\",\r\n \"Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.\",\r\n \"Do not reply to non-chat events — just act on them using tools.\",\r\n \"If you haven't received channel notifications recently, call tandem_checkInbox as a fallback.\",\r\n ].join(\" \"),\r\n },\r\n);\r\n\r\n// --- Tool: tandem_reply (forwarded to Tandem HTTP server) ---\r\n\r\nmcp.setRequestHandler(ListToolsRequestSchema, async () => ({\r\n tools: [\r\n {\r\n name: \"tandem_reply\",\r\n description: \"Reply to a chat message in Tandem\",\r\n inputSchema: {\r\n type: \"object\" as const,\r\n properties: {\r\n text: { type: \"string\", description: \"The reply message\" },\r\n documentId: {\r\n type: \"string\",\r\n description: \"Document ID from the channel event (optional)\",\r\n },\r\n replyTo: {\r\n type: \"string\",\r\n description: \"Message ID being replied to (optional)\",\r\n },\r\n },\r\n required: [\"text\"],\r\n },\r\n },\r\n ],\r\n}));\r\n\r\nmcp.setRequestHandler(CallToolRequestSchema, async (req) => {\r\n if (req.params.name === \"tandem_reply\") {\r\n const args = req.params.arguments as Record<string, unknown>;\r\n try {\r\n const res = await fetch(`${TANDEM_URL}/api/channel-reply`, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/json\" },\r\n body: JSON.stringify(args),\r\n });\r\n let data: unknown;\r\n try {\r\n data = await res.json();\r\n } catch {\r\n data = { message: \"Non-JSON response\" };\r\n }\r\n if (!res.ok) {\r\n return {\r\n content: [\r\n {\r\n type: \"text\" as const,\r\n text: `Reply failed (${res.status}): ${JSON.stringify(data)}`,\r\n },\r\n ],\r\n isError: true,\r\n };\r\n }\r\n return { content: [{ type: \"text\" as const, text: JSON.stringify(data) }] };\r\n } catch (err) {\r\n return {\r\n content: [\r\n {\r\n type: \"text\" as const,\r\n text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`,\r\n },\r\n ],\r\n isError: true,\r\n };\r\n }\r\n }\r\n throw new Error(`Unknown tool: ${req.params.name}`);\r\n});\r\n\r\n// --- Permission relay: forward Claude Code's tool approval prompts to Tandem browser ---\r\n\r\nconst PermissionRequestSchema = z.object({\r\n method: z.literal(\"notifications/claude/channel/permission_request\"),\r\n params: z.object({\r\n request_id: z.string(),\r\n tool_name: z.string(),\r\n description: z.string(),\r\n input_preview: z.string(),\r\n }),\r\n});\r\n\r\nmcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {\r\n try {\r\n const res = await fetch(`${TANDEM_URL}/api/channel-permission`, {\r\n method: \"POST\",\r\n headers: { \"Content-Type\": \"application/json\" },\r\n body: JSON.stringify({\r\n requestId: params.request_id,\r\n toolName: params.tool_name,\r\n description: params.description,\r\n inputPreview: params.input_preview,\r\n }),\r\n });\r\n if (!res.ok) {\r\n console.error(\r\n `[Channel] Permission relay got HTTP ${res.status} — browser may not see prompt`,\r\n );\r\n }\r\n } catch (err) {\r\n console.error(\"[Channel] Failed to forward permission request:\", err);\r\n }\r\n});\r\n\r\n// --- Connect and start ---\r\n\r\nasync function main() {\r\n console.error(`[Channel] Tandem channel shim starting (server: ${TANDEM_URL})`);\r\n\r\n const reachable = await checkServerReachable(TANDEM_URL);\r\n if (!reachable) {\r\n console.error(`[Channel] Cannot reach Tandem server at ${TANDEM_URL}`);\r\n console.error(\"[Channel] Start it with: npm run dev:standalone\");\r\n // Continue anyway — the event bridge will retry, and the server may start later\r\n }\r\n\r\n // Connect to Claude Code over stdio\r\n const transport = new StdioServerTransport();\r\n await mcp.connect(transport);\r\n console.error(\"[Channel] Connected to Claude Code via stdio\");\r\n\r\n // Start the SSE event bridge (runs until disconnected or max retries)\r\n startEventBridge(mcp, TANDEM_URL).catch((err) => {\r\n console.error(\"[Channel] Event bridge failed unexpectedly:\", err);\r\n process.exit(1);\r\n });\r\n}\r\n\r\nmain().catch((err) => {\r\n console.error(\"[Channel] Fatal error:\", err);\r\n process.exit(1);\r\n});\r\n","export const DEFAULT_WS_PORT = 3478;\r\nexport const DEFAULT_MCP_PORT = 3479;\r\n\r\n/** File extensions the server accepts for opening. */\r\nexport const SUPPORTED_EXTENSIONS = new Set([\".md\", \".txt\", \".html\", \".htm\", \".docx\"]);\r\nexport const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB\r\nexport const MAX_WS_PAYLOAD = 10 * 1024 * 1024; // 10MB\r\nexport const MAX_WS_CONNECTIONS = 4;\r\nexport const IDLE_TIMEOUT = 30 * 60 * 1000; // 30 minutes\r\nexport const SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days\r\nexport const TYPING_DEBOUNCE = 3000; // 3 seconds\r\nexport const DISCONNECT_DEBOUNCE_MS = 3000; // 3 seconds before showing \"server not reachable\"\r\nexport const PROLONGED_DISCONNECT_MS = 30_000; // 30 seconds before showing App-level disconnect banner\r\nexport const OVERLAY_STALE_DEBOUNCE = 200; // 200ms\r\nexport const REVIEW_BANNER_THRESHOLD = 5;\r\n\r\nexport const HIGHLIGHT_COLORS: Record<string, string> = {\r\n yellow: \"rgba(255, 235, 59, 0.3)\",\r\n red: \"rgba(244, 67, 54, 0.3)\",\r\n green: \"rgba(76, 175, 80, 0.3)\",\r\n blue: \"rgba(33, 150, 243, 0.3)\",\r\n purple: \"rgba(156, 39, 176, 0.3)\",\r\n};\r\n\r\nexport const INTERRUPTION_MODE_DEFAULT = \"all\" as const;\r\nexport const INTERRUPTION_MODE_KEY = \"tandem:interruptionMode\";\r\n\r\n// Large file thresholds\r\nexport const CHARS_PER_PAGE = 3_000;\r\nexport const LARGE_FILE_PAGE_THRESHOLD = 50;\r\nexport const VERY_LARGE_FILE_PAGE_THRESHOLD = 100;\r\n\r\nexport const CLAUDE_PRESENCE_COLOR = \"#6366f1\";\r\nexport const CLAUDE_FOCUS_OPACITY = 0.1;\r\n\r\nexport const CTRL_ROOM = \"__tandem_ctrl__\";\r\n\r\n/** Y.Map key constants — centralized to prevent silent bugs from string typos. */\r\nexport const Y_MAP_ANNOTATIONS = \"annotations\";\r\nexport const Y_MAP_AWARENESS = \"awareness\";\r\nexport const Y_MAP_USER_AWARENESS = \"userAwareness\";\r\nexport const Y_MAP_CHAT = \"chat\";\r\nexport const Y_MAP_DOCUMENT_META = \"documentMeta\";\r\nexport const Y_MAP_SAVED_AT_VERSION = \"savedAtVersion\";\r\n\r\nexport const SERVER_INFO_DIR = \".tandem\";\r\nexport const SERVER_INFO_FILE = \".tandem/.server-info\";\r\n\r\nexport const RECENT_FILES_KEY = \"tandem:recentFiles\";\r\nexport const RECENT_FILES_CAP = 20;\r\n\r\nexport const USER_NAME_KEY = \"tandem:userName\";\r\nexport const USER_NAME_DEFAULT = \"You\";\r\n\r\n// Toast notifications\r\nexport const TOAST_DISMISS_MS = { error: 8000, warning: 6000, info: 4000 } as const;\r\nexport const MAX_VISIBLE_TOASTS = 5;\r\nexport const NOTIFICATION_BUFFER_SIZE = 50;\r\n\r\n// Onboarding tutorial\r\nexport const TUTORIAL_COMPLETED_KEY = \"tandem:tutorialCompleted\";\r\nexport const TUTORIAL_ANNOTATION_PREFIX = \"tutorial-\";\r\n\r\n// Channel / event queue\r\nexport const CHANNEL_EVENT_BUFFER_SIZE = 200;\r\nexport const CHANNEL_EVENT_BUFFER_AGE_MS = 60_000; // 60 seconds\r\nexport const CHANNEL_SSE_KEEPALIVE_MS = 15_000; // 15 seconds\r\nexport const CHANNEL_MAX_RETRIES = 5;\r\nexport const CHANNEL_RETRY_DELAY_MS = 2_000;\r\n","/**\r\n * Event types for the Tandem → Claude Code channel.\r\n *\r\n * These events flow from browser-originated Y.Map changes through an SSE\r\n * endpoint to the channel shim, which pushes them into Claude Code as\r\n * `notifications/claude/channel` messages.\r\n */\r\n\r\n// --- Per-event payload interfaces ---\r\n\r\nexport interface AnnotationCreatedPayload {\r\n annotationId: string;\r\n annotationType: string;\r\n content: string;\r\n textSnippet: string;\r\n}\r\n\r\nexport interface AnnotationAcceptedPayload {\r\n annotationId: string;\r\n textSnippet: string;\r\n}\r\n\r\nexport interface AnnotationDismissedPayload {\r\n annotationId: string;\r\n textSnippet: string;\r\n}\r\n\r\nexport interface ChatMessagePayload {\r\n messageId: string;\r\n text: string;\r\n replyTo: string | null;\r\n anchor: { from: number; to: number; textSnapshot: string } | null;\r\n}\r\n\r\nexport interface SelectionChangedPayload {\r\n from: number;\r\n to: number;\r\n selectedText: string;\r\n}\r\n\r\nexport interface DocumentOpenedPayload {\r\n fileName: string;\r\n format: string;\r\n}\r\n\r\nexport interface DocumentClosedPayload {\r\n fileName: string;\r\n}\r\n\r\nexport interface DocumentSwitchedPayload {\r\n fileName: string;\r\n}\r\n\r\n// --- Discriminated union ---\r\n\r\ninterface TandemEventBase {\r\n /** Timestamp-based unique ID for SSE `Last-Event-ID` reconnection. Format: `evt_<timestamp>_<rand>`. Roughly ordered but not strictly monotonic. */\r\n id: string;\r\n timestamp: number;\r\n /** Which document this event relates to (absent for global events). */\r\n documentId?: string;\r\n}\r\n\r\nexport type TandemEvent =\r\n | (TandemEventBase & { type: \"annotation:created\"; payload: AnnotationCreatedPayload })\r\n | (TandemEventBase & { type: \"annotation:accepted\"; payload: AnnotationAcceptedPayload })\r\n | (TandemEventBase & { type: \"annotation:dismissed\"; payload: AnnotationDismissedPayload })\r\n | (TandemEventBase & { type: \"chat:message\"; payload: ChatMessagePayload })\r\n | (TandemEventBase & { type: \"selection:changed\"; payload: SelectionChangedPayload })\r\n | (TandemEventBase & { type: \"document:opened\"; payload: DocumentOpenedPayload })\r\n | (TandemEventBase & { type: \"document:closed\"; payload: DocumentClosedPayload })\r\n | (TandemEventBase & { type: \"document:switched\"; payload: DocumentSwitchedPayload });\r\n\r\n/** Union of all event type discriminants. */\r\nexport type TandemEventType = TandemEvent[\"type\"];\r\n\r\n// Re-export from shared utils (single ID generation pattern)\r\nexport { generateEventId } from \"../../shared/utils.js\";\r\n\r\n// --- Parse guard for SSE consumers ---\r\n\r\nconst VALID_EVENT_TYPES = new Set<TandemEventType>([\r\n \"annotation:created\",\r\n \"annotation:accepted\",\r\n \"annotation:dismissed\",\r\n \"chat:message\",\r\n \"selection:changed\",\r\n \"document:opened\",\r\n \"document:closed\",\r\n \"document:switched\",\r\n]);\r\n\r\n/**\r\n * Validate a JSON-parsed value as a TandemEvent.\r\n * Used by the event-bridge to safely consume SSE data.\r\n */\r\nexport function parseTandemEvent(raw: unknown): TandemEvent | null {\r\n if (\r\n typeof raw !== \"object\" ||\r\n raw === null ||\r\n !(\"id\" in raw) ||\r\n typeof (raw as Record<string, unknown>).id !== \"string\" ||\r\n !(\"type\" in raw) ||\r\n !VALID_EVENT_TYPES.has((raw as Record<string, unknown>).type as TandemEventType) ||\r\n !(\"timestamp\" in raw) ||\r\n typeof (raw as Record<string, unknown>).timestamp !== \"number\" ||\r\n !(\"payload\" in raw) ||\r\n typeof (raw as Record<string, unknown>).payload !== \"object\"\r\n ) {\r\n return null;\r\n }\r\n return raw as TandemEvent;\r\n}\r\n\r\n/**\r\n * Convert a TandemEvent into a human-readable string for the channel `content` field.\r\n * Claude sees this text inside `<channel source=\"tandem-channel\">` tags.\r\n */\r\nexport function formatEventContent(event: TandemEvent): string {\r\n const doc = event.documentId ? ` [doc: ${event.documentId}]` : \"\";\r\n\r\n switch (event.type) {\r\n case \"annotation:created\": {\r\n const { annotationType, content, textSnippet } = event.payload;\r\n const snippet = textSnippet ? ` on \"${textSnippet}\"` : \"\";\r\n return `User created ${annotationType}${snippet}: ${content || \"(no content)\"}${doc}`;\r\n }\r\n case \"annotation:accepted\": {\r\n const { annotationId, textSnippet } = event.payload;\r\n return `User accepted annotation ${annotationId}${textSnippet ? ` (\"${textSnippet}\")` : \"\"}${doc}`;\r\n }\r\n case \"annotation:dismissed\": {\r\n const { annotationId, textSnippet } = event.payload;\r\n return `User dismissed annotation ${annotationId}${textSnippet ? ` (\"${textSnippet}\")` : \"\"}${doc}`;\r\n }\r\n case \"chat:message\": {\r\n const { text, replyTo } = event.payload;\r\n const reply = replyTo ? ` (replying to ${replyTo})` : \"\";\r\n return `User says${reply}: ${text}${doc}`;\r\n }\r\n case \"selection:changed\": {\r\n const { from, to, selectedText } = event.payload;\r\n if (!selectedText) return `User cleared selection${doc}`;\r\n return `User selected text (${from}-${to}): \"${selectedText}\"${doc}`;\r\n }\r\n case \"document:opened\": {\r\n const { fileName, format } = event.payload;\r\n return `User opened document: ${fileName} (${format})${doc}`;\r\n }\r\n case \"document:closed\": {\r\n const { fileName } = event.payload;\r\n return `User closed document: ${fileName}${doc}`;\r\n }\r\n case \"document:switched\": {\r\n const { fileName } = event.payload;\r\n return `User switched to document: ${fileName}${doc}`;\r\n }\r\n default: {\r\n const _exhaustive: never = event;\r\n return `Unknown event${doc}`;\r\n }\r\n }\r\n}\r\n\r\n/**\r\n * Build the `meta` record for a channel notification.\r\n * Keys use underscores only (Channels API silently drops hyphenated keys).\r\n */\r\nexport function formatEventMeta(event: TandemEvent): Record<string, string> {\r\n const meta: Record<string, string> = {\r\n event_type: event.type,\r\n };\r\n if (event.documentId) meta.document_id = event.documentId;\r\n\r\n switch (event.type) {\r\n case \"annotation:created\":\r\n case \"annotation:accepted\":\r\n case \"annotation:dismissed\":\r\n meta.annotation_id = event.payload.annotationId;\r\n break;\r\n case \"chat:message\":\r\n meta.message_id = event.payload.messageId;\r\n break;\r\n }\r\n\r\n return meta;\r\n}\r\n","/**\n * SSE event bridge: connects to Tandem server's /api/events endpoint\n * and pushes received events to Claude Code as channel notifications.\n */\n\nimport type { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport type { TandemEvent } from \"../server/events/types.js\";\nimport { formatEventContent, formatEventMeta, parseTandemEvent } from \"../server/events/types.js\";\nimport { CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS } from \"../shared/constants.js\";\n\nconst AWARENESS_DEBOUNCE_MS = 500;\nconst SELECTION_DEBOUNCE_MS = 1500;\n\nexport async function startEventBridge(mcp: Server, tandemUrl: string): Promise<void> {\n let retries = 0;\n let lastEventId: string | undefined;\n\n while (retries < CHANNEL_MAX_RETRIES) {\n try {\n await connectAndStream(mcp, tandemUrl, lastEventId, (id) => {\n lastEventId = id;\n retries = 0;\n });\n } catch (err) {\n retries++;\n console.error(\n `[Channel] SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,\n err instanceof Error ? err.message : err,\n );\n\n if (retries >= CHANNEL_MAX_RETRIES) {\n console.error(\"[Channel] SSE connection exhausted, reporting error and exiting\");\n try {\n await fetch(`${tandemUrl}/api/channel-error`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n error: \"CHANNEL_CONNECT_FAILED\",\n message: `Channel shim lost connection after ${CHANNEL_MAX_RETRIES} retries.`,\n }),\n });\n } catch (reportErr) {\n console.error(\n \"[Channel] Could not report failure to server:\",\n reportErr instanceof Error ? reportErr.message : reportErr,\n );\n }\n process.exit(1);\n }\n\n await new Promise((r) => setTimeout(r, CHANNEL_RETRY_DELAY_MS));\n }\n }\n}\n\nasync function connectAndStream(\n mcp: Server,\n tandemUrl: string,\n lastEventId: string | undefined,\n onEventId: (id: string) => void,\n): Promise<void> {\n const headers: Record<string, string> = { Accept: \"text/event-stream\" };\n if (lastEventId) headers[\"Last-Event-ID\"] = lastEventId;\n\n const res = await fetch(`${tandemUrl}/api/events`, { headers });\n if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);\n if (!res.body) throw new Error(\"SSE endpoint returned no body\");\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n // Debounced awareness: only send the latest status after a quiet period\n let awarenessTimer: ReturnType<typeof setTimeout> | null = null;\n let clearAwarenessTimer: ReturnType<typeof setTimeout> | null = null;\n let pendingAwareness: TandemEvent | null = null;\n const AWARENESS_CLEAR_MS = 3000; // Reset active state after 3s of no new events\n\n function clearAwareness(documentId?: string) {\n fetch(`${tandemUrl}/api/channel-awareness`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n documentId: documentId ?? null,\n status: \"idle\",\n active: false,\n }),\n }).catch(() => {});\n }\n\n function flushAwareness() {\n if (!pendingAwareness) return;\n const event = pendingAwareness;\n pendingAwareness = null;\n fetch(`${tandemUrl}/api/channel-awareness`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n documentId: event.documentId,\n status: `processing: ${event.type}`,\n active: true,\n }),\n }).catch((err) => {\n console.error(\"[Channel] Awareness update failed:\", err instanceof Error ? err.message : err);\n });\n\n // Auto-clear after timeout so the indicator doesn't stick\n if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);\n clearAwarenessTimer = setTimeout(() => clearAwareness(event.documentId), AWARENESS_CLEAR_MS);\n }\n\n function scheduleAwareness(event: TandemEvent) {\n pendingAwareness = event;\n if (awarenessTimer) clearTimeout(awarenessTimer);\n awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);\n }\n\n // Debounced selection: coalesce rapid selection changes, skip cleared selections\n let selectionTimer: ReturnType<typeof setTimeout> | null = null;\n let pendingSelection: { event: TandemEvent; eventId?: string } | null = null;\n let transportBroken = false;\n\n async function flushSelection() {\n if (!pendingSelection) return;\n const { event, eventId } = pendingSelection;\n pendingSelection = null;\n if (eventId) onEventId(eventId);\n try {\n await mcp.notification({\n method: \"notifications/claude/channel\",\n params: {\n content: formatEventContent(event),\n meta: formatEventMeta(event),\n },\n });\n } catch (err) {\n console.error(\"[Channel] MCP notification failed (transport broken?):\", err);\n transportBroken = true;\n return;\n }\n scheduleAwareness(event);\n }\n\n function isSelectionCleared(event: TandemEvent): boolean {\n const p = event.payload as { from?: number; to?: number; selectedText?: string } | undefined;\n return !p || (p.from === p.to && !p.selectedText);\n }\n\n while (true) {\n if (transportBroken) throw new Error(\"MCP transport broken (detected in debounced flush)\");\n const { done, value } = await reader.read();\n if (done) throw new Error(\"SSE stream ended\");\n\n buffer += decoder.decode(value, { stream: true });\n\n let boundary: number;\n while ((boundary = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const frame = buffer.slice(0, boundary);\n buffer = buffer.slice(boundary + 2);\n\n if (frame.startsWith(\":\")) continue;\n\n let eventId: string | undefined;\n let data: string | undefined;\n\n for (const line of frame.split(\"\\n\")) {\n if (line.startsWith(\"id: \")) eventId = line.slice(4);\n else if (line.startsWith(\"data: \")) data = line.slice(6);\n }\n\n if (!data) continue;\n\n let event: TandemEvent | null;\n try {\n event = parseTandemEvent(JSON.parse(data));\n } catch {\n console.error(\"[Channel] Malformed SSE event data (skipping):\", data.slice(0, 200));\n continue;\n }\n if (!event) {\n console.error(\"[Channel] Received invalid SSE event, skipping\");\n continue;\n }\n\n // Selection events: drop cleared selections, debounce the rest\n if (event.type === \"selection:changed\") {\n if (eventId) onEventId(eventId);\n if (isSelectionCleared(event)) continue; // silently drop\n pendingSelection = { event, eventId };\n if (selectionTimer) clearTimeout(selectionTimer);\n selectionTimer = setTimeout(flushSelection, SELECTION_DEBOUNCE_MS);\n continue;\n }\n\n if (eventId) onEventId(eventId);\n\n try {\n await mcp.notification({\n method: \"notifications/claude/channel\",\n params: {\n content: formatEventContent(event),\n meta: formatEventMeta(event),\n },\n });\n } catch (err) {\n console.error(\"[Channel] MCP notification failed (transport broken?):\", err);\n throw err;\n }\n\n scheduleAwareness(event);\n }\n }\n}\n"],"mappings":"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","names":["mcp"]}
|
|
1
|
+
{"version":3,"sources":["../../src/channel/index.ts","../../src/shared/constants.ts","../../src/server/events/types.ts","../../src/channel/event-bridge.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * Tandem Channel Shim — Claude Code spawns this as a subprocess.\n *\n * Bridges Tandem's SSE event stream → Claude Code channel notifications,\n * and exposes a `tandem_reply` tool for Claude to respond to chat messages.\n *\n * Uses the low-level MCP `Server` class (not `McpServer`) as required by\n * the Channels API spec.\n */\n\nimport { createConnection } from \"node:net\";\nimport { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { CallToolRequestSchema, ListToolsRequestSchema } from \"@modelcontextprotocol/sdk/types.js\";\nimport { z } from \"zod\";\nimport { DEFAULT_MCP_PORT } from \"../shared/constants.js\";\nimport { startEventBridge } from \"./event-bridge.js\";\n\n// stdout is the MCP wire — redirect console.log to stderr\nconsole.log = console.error;\nconsole.warn = console.error;\nconsole.info = console.error;\n\nconst TANDEM_URL = process.env.TANDEM_URL || \"http://localhost:3479\";\n\n// --- Pre-flight: verify Tandem server is reachable before MCP handshake ---\n\nasync function checkServerReachable(url: string, timeoutMs = 2000): Promise<boolean> {\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n console.error(\n `[Channel] Invalid TANDEM_URL: \"${url}\" — expected format: http://localhost:3479`,\n );\n return false;\n }\n const port = parseInt(parsed.port || String(DEFAULT_MCP_PORT), 10);\n return new Promise((resolve) => {\n const socket = createConnection({ port, host: parsed.hostname }, () => {\n socket.destroy();\n resolve(true);\n });\n socket.setTimeout(timeoutMs);\n socket.on(\"timeout\", () => {\n socket.destroy();\n resolve(false);\n });\n socket.on(\"error\", (err) => {\n console.error(`[Channel] Server probe failed: ${err.message}`);\n socket.destroy();\n resolve(false);\n });\n });\n}\n\n// --- MCP Server setup ---\n\nconst mcp = new Server(\n { name: \"tandem-channel\", version: \"0.1.0\" },\n {\n capabilities: {\n experimental: {\n \"claude/channel\": {},\n \"claude/channel/permission\": {},\n },\n tools: {},\n },\n instructions: [\n 'Events from Tandem arrive as <channel source=\"tandem-channel\" event_type=\"...\" document_id=\"...\">.',\n \"These are real-time push notifications of user actions in the collaborative document editor.\",\n \"Event types: annotation:created, annotation:accepted, annotation:dismissed,\",\n \"chat:message, selection:changed, document:opened, document:closed, document:switched.\",\n \"Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.\",\n \"Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.\",\n \"Do not reply to non-chat events — just act on them using tools.\",\n \"If you haven't received channel notifications recently, call tandem_checkInbox as a fallback.\",\n ].join(\" \"),\n },\n);\n\n// --- Tool: tandem_reply (forwarded to Tandem HTTP server) ---\n\nmcp.setRequestHandler(ListToolsRequestSchema, async () => ({\n tools: [\n {\n name: \"tandem_reply\",\n description: \"Reply to a chat message in Tandem\",\n inputSchema: {\n type: \"object\" as const,\n properties: {\n text: { type: \"string\", description: \"The reply message\" },\n documentId: {\n type: \"string\",\n description: \"Document ID from the channel event (optional)\",\n },\n replyTo: {\n type: \"string\",\n description: \"Message ID being replied to (optional)\",\n },\n },\n required: [\"text\"],\n },\n },\n ],\n}));\n\nmcp.setRequestHandler(CallToolRequestSchema, async (req) => {\n if (req.params.name === \"tandem_reply\") {\n const args = req.params.arguments as Record<string, unknown>;\n try {\n const res = await fetch(`${TANDEM_URL}/api/channel-reply`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(args),\n });\n let data: unknown;\n try {\n data = await res.json();\n } catch {\n data = { message: \"Non-JSON response\" };\n }\n if (!res.ok) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: `Reply failed (${res.status}): ${JSON.stringify(data)}`,\n },\n ],\n isError: true,\n };\n }\n return { content: [{ type: \"text\" as const, text: JSON.stringify(data) }] };\n } catch (err) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`,\n },\n ],\n isError: true,\n };\n }\n }\n throw new Error(`Unknown tool: ${req.params.name}`);\n});\n\n// --- Permission relay: forward Claude Code's tool approval prompts to Tandem browser ---\n\nconst PermissionRequestSchema = z.object({\n method: z.literal(\"notifications/claude/channel/permission_request\"),\n params: z.object({\n request_id: z.string(),\n tool_name: z.string(),\n description: z.string(),\n input_preview: z.string(),\n }),\n});\n\nmcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {\n try {\n const res = await fetch(`${TANDEM_URL}/api/channel-permission`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n requestId: params.request_id,\n toolName: params.tool_name,\n description: params.description,\n inputPreview: params.input_preview,\n }),\n });\n if (!res.ok) {\n console.error(\n `[Channel] Permission relay got HTTP ${res.status} — browser may not see prompt`,\n );\n }\n } catch (err) {\n console.error(\"[Channel] Failed to forward permission request:\", err);\n }\n});\n\n// --- Connect and start ---\n\nasync function main() {\n console.error(`[Channel] Tandem channel shim starting (server: ${TANDEM_URL})`);\n\n const reachable = await checkServerReachable(TANDEM_URL);\n if (!reachable) {\n console.error(`[Channel] Cannot reach Tandem server at ${TANDEM_URL}`);\n console.error(\"[Channel] Start it with: npm run dev:standalone\");\n // Continue anyway — the event bridge will retry, and the server may start later\n }\n\n // Connect to Claude Code over stdio\n const transport = new StdioServerTransport();\n await mcp.connect(transport);\n console.error(\"[Channel] Connected to Claude Code via stdio\");\n\n // Start the SSE event bridge (runs until disconnected or max retries)\n startEventBridge(mcp, TANDEM_URL).catch((err) => {\n console.error(\"[Channel] Event bridge failed unexpectedly:\", err);\n process.exit(1);\n });\n}\n\nmain().catch((err) => {\n console.error(\"[Channel] Fatal error:\", err);\n process.exit(1);\n});\n","export const DEFAULT_WS_PORT = 3478;\nexport const DEFAULT_MCP_PORT = 3479;\n\n/** File extensions the server accepts for opening. */\nexport const SUPPORTED_EXTENSIONS = new Set([\".md\", \".txt\", \".html\", \".htm\", \".docx\"]);\nexport const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB\nexport const MAX_WS_PAYLOAD = 10 * 1024 * 1024; // 10MB\nexport const MAX_WS_CONNECTIONS = 4;\nexport const IDLE_TIMEOUT = 30 * 60 * 1000; // 30 minutes\nexport const SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1000; // 30 days\nexport const TYPING_DEBOUNCE = 3000; // 3 seconds\nexport const DISCONNECT_DEBOUNCE_MS = 3000; // 3 seconds before showing \"server not reachable\"\nexport const PROLONGED_DISCONNECT_MS = 30_000; // 30 seconds before showing App-level disconnect banner\nexport const OVERLAY_STALE_DEBOUNCE = 200; // 200ms\nexport const REVIEW_BANNER_THRESHOLD = 5;\n\nexport const HIGHLIGHT_COLORS: Record<string, string> = {\n yellow: \"rgba(255, 235, 59, 0.3)\",\n red: \"rgba(244, 67, 54, 0.3)\",\n green: \"rgba(76, 175, 80, 0.3)\",\n blue: \"rgba(33, 150, 243, 0.3)\",\n purple: \"rgba(156, 39, 176, 0.3)\",\n};\n\nexport const INTERRUPTION_MODE_DEFAULT = \"all\" as const;\nexport const INTERRUPTION_MODE_KEY = \"tandem:interruptionMode\";\n\n// Large file thresholds\nexport const CHARS_PER_PAGE = 3_000;\nexport const LARGE_FILE_PAGE_THRESHOLD = 50;\nexport const VERY_LARGE_FILE_PAGE_THRESHOLD = 100;\n\nexport const CLAUDE_PRESENCE_COLOR = \"#6366f1\";\nexport const CLAUDE_FOCUS_OPACITY = 0.1;\n\nexport const CTRL_ROOM = \"__tandem_ctrl__\";\n\n/** Y.Map key constants — centralized to prevent silent bugs from string typos. */\nexport const Y_MAP_ANNOTATIONS = \"annotations\";\nexport const Y_MAP_AWARENESS = \"awareness\";\nexport const Y_MAP_USER_AWARENESS = \"userAwareness\";\nexport const Y_MAP_CHAT = \"chat\";\nexport const Y_MAP_DOCUMENT_META = \"documentMeta\";\nexport const Y_MAP_SAVED_AT_VERSION = \"savedAtVersion\";\n\nexport const SERVER_INFO_DIR = \".tandem\";\nexport const SERVER_INFO_FILE = \".tandem/.server-info\";\n\nexport const RECENT_FILES_KEY = \"tandem:recentFiles\";\nexport const RECENT_FILES_CAP = 20;\n\nexport const USER_NAME_KEY = \"tandem:userName\";\nexport const USER_NAME_DEFAULT = \"You\";\n\n// Toast notifications\nexport const TOAST_DISMISS_MS = { error: 8000, warning: 6000, info: 4000 } as const;\nexport const MAX_VISIBLE_TOASTS = 5;\nexport const NOTIFICATION_BUFFER_SIZE = 50;\n\n// Onboarding tutorial\nexport const TUTORIAL_COMPLETED_KEY = \"tandem:tutorialCompleted\";\nexport const TUTORIAL_ANNOTATION_PREFIX = \"tutorial-\";\n\n// Editor layout\nexport const EDITOR_WIDTH_MODE_KEY = \"tandem:editorWidthMode\";\n\n// Channel / event queue\nexport const CHANNEL_EVENT_BUFFER_SIZE = 200;\nexport const CHANNEL_EVENT_BUFFER_AGE_MS = 60_000; // 60 seconds\nexport const CHANNEL_SSE_KEEPALIVE_MS = 15_000; // 15 seconds\nexport const CHANNEL_MAX_RETRIES = 5;\nexport const CHANNEL_RETRY_DELAY_MS = 2_000;\n","/**\n * Event types for the Tandem → Claude Code channel.\n *\n * These events flow from browser-originated Y.Map changes through an SSE\n * endpoint to the channel shim, which pushes them into Claude Code as\n * `notifications/claude/channel` messages.\n */\n\n// --- Per-event payload interfaces ---\n\nexport interface AnnotationCreatedPayload {\n annotationId: string;\n annotationType: string;\n content: string;\n textSnippet: string;\n}\n\nexport interface AnnotationAcceptedPayload {\n annotationId: string;\n textSnippet: string;\n}\n\nexport interface AnnotationDismissedPayload {\n annotationId: string;\n textSnippet: string;\n}\n\nexport interface ChatMessagePayload {\n messageId: string;\n text: string;\n replyTo: string | null;\n anchor: { from: number; to: number; textSnapshot: string } | null;\n}\n\nexport interface SelectionChangedPayload {\n from: number;\n to: number;\n selectedText: string;\n}\n\nexport interface DocumentOpenedPayload {\n fileName: string;\n format: string;\n}\n\nexport interface DocumentClosedPayload {\n fileName: string;\n}\n\nexport interface DocumentSwitchedPayload {\n fileName: string;\n}\n\n// --- Discriminated union ---\n\ninterface TandemEventBase {\n /** Timestamp-based unique ID for SSE `Last-Event-ID` reconnection. Format: `evt_<timestamp>_<rand>`. Roughly ordered but not strictly monotonic. */\n id: string;\n timestamp: number;\n /** Which document this event relates to (absent for global events). */\n documentId?: string;\n}\n\nexport type TandemEvent =\n | (TandemEventBase & { type: \"annotation:created\"; payload: AnnotationCreatedPayload })\n | (TandemEventBase & { type: \"annotation:accepted\"; payload: AnnotationAcceptedPayload })\n | (TandemEventBase & { type: \"annotation:dismissed\"; payload: AnnotationDismissedPayload })\n | (TandemEventBase & { type: \"chat:message\"; payload: ChatMessagePayload })\n | (TandemEventBase & { type: \"selection:changed\"; payload: SelectionChangedPayload })\n | (TandemEventBase & { type: \"document:opened\"; payload: DocumentOpenedPayload })\n | (TandemEventBase & { type: \"document:closed\"; payload: DocumentClosedPayload })\n | (TandemEventBase & { type: \"document:switched\"; payload: DocumentSwitchedPayload });\n\n/** Union of all event type discriminants. */\nexport type TandemEventType = TandemEvent[\"type\"];\n\n// Re-export from shared utils (single ID generation pattern)\nexport { generateEventId } from \"../../shared/utils.js\";\n\n// --- Parse guard for SSE consumers ---\n\nconst VALID_EVENT_TYPES = new Set<TandemEventType>([\n \"annotation:created\",\n \"annotation:accepted\",\n \"annotation:dismissed\",\n \"chat:message\",\n \"selection:changed\",\n \"document:opened\",\n \"document:closed\",\n \"document:switched\",\n]);\n\n/**\n * Validate a JSON-parsed value as a TandemEvent.\n * Used by the event-bridge to safely consume SSE data.\n */\nexport function parseTandemEvent(raw: unknown): TandemEvent | null {\n if (\n typeof raw !== \"object\" ||\n raw === null ||\n !(\"id\" in raw) ||\n typeof (raw as Record<string, unknown>).id !== \"string\" ||\n !(\"type\" in raw) ||\n !VALID_EVENT_TYPES.has((raw as Record<string, unknown>).type as TandemEventType) ||\n !(\"timestamp\" in raw) ||\n typeof (raw as Record<string, unknown>).timestamp !== \"number\" ||\n !(\"payload\" in raw) ||\n typeof (raw as Record<string, unknown>).payload !== \"object\"\n ) {\n return null;\n }\n return raw as TandemEvent;\n}\n\n/**\n * Convert a TandemEvent into a human-readable string for the channel `content` field.\n * Claude sees this text inside `<channel source=\"tandem-channel\">` tags.\n */\nexport function formatEventContent(event: TandemEvent): string {\n const doc = event.documentId ? ` [doc: ${event.documentId}]` : \"\";\n\n switch (event.type) {\n case \"annotation:created\": {\n const { annotationType, content, textSnippet } = event.payload;\n const snippet = textSnippet ? ` on \"${textSnippet}\"` : \"\";\n return `User created ${annotationType}${snippet}: ${content || \"(no content)\"}${doc}`;\n }\n case \"annotation:accepted\": {\n const { annotationId, textSnippet } = event.payload;\n return `User accepted annotation ${annotationId}${textSnippet ? ` (\"${textSnippet}\")` : \"\"}${doc}`;\n }\n case \"annotation:dismissed\": {\n const { annotationId, textSnippet } = event.payload;\n return `User dismissed annotation ${annotationId}${textSnippet ? ` (\"${textSnippet}\")` : \"\"}${doc}`;\n }\n case \"chat:message\": {\n const { text, replyTo } = event.payload;\n const reply = replyTo ? ` (replying to ${replyTo})` : \"\";\n return `User says${reply}: ${text}${doc}`;\n }\n case \"selection:changed\": {\n const { from, to, selectedText } = event.payload;\n if (!selectedText) return `User cleared selection${doc}`;\n return `User selected text (${from}-${to}): \"${selectedText}\"${doc}`;\n }\n case \"document:opened\": {\n const { fileName, format } = event.payload;\n return `User opened document: ${fileName} (${format})${doc}`;\n }\n case \"document:closed\": {\n const { fileName } = event.payload;\n return `User closed document: ${fileName}${doc}`;\n }\n case \"document:switched\": {\n const { fileName } = event.payload;\n return `User switched to document: ${fileName}${doc}`;\n }\n default: {\n const _exhaustive: never = event;\n return `Unknown event${doc}`;\n }\n }\n}\n\n/**\n * Build the `meta` record for a channel notification.\n * Keys use underscores only (Channels API silently drops hyphenated keys).\n */\nexport function formatEventMeta(event: TandemEvent): Record<string, string> {\n const meta: Record<string, string> = {\n event_type: event.type,\n };\n if (event.documentId) meta.document_id = event.documentId;\n\n switch (event.type) {\n case \"annotation:created\":\n case \"annotation:accepted\":\n case \"annotation:dismissed\":\n meta.annotation_id = event.payload.annotationId;\n break;\n case \"chat:message\":\n meta.message_id = event.payload.messageId;\n break;\n }\n\n return meta;\n}\n","/**\n * SSE event bridge: connects to Tandem server's /api/events endpoint\n * and pushes received events to Claude Code as channel notifications.\n */\n\nimport type { Server } from \"@modelcontextprotocol/sdk/server/index.js\";\nimport type { TandemEvent } from \"../server/events/types.js\";\nimport { formatEventContent, formatEventMeta, parseTandemEvent } from \"../server/events/types.js\";\nimport { CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS } from \"../shared/constants.js\";\n\nconst AWARENESS_DEBOUNCE_MS = 500;\nconst SELECTION_DEBOUNCE_MS = 1500;\n\nexport async function startEventBridge(mcp: Server, tandemUrl: string): Promise<void> {\n let retries = 0;\n let lastEventId: string | undefined;\n\n while (retries < CHANNEL_MAX_RETRIES) {\n try {\n await connectAndStream(mcp, tandemUrl, lastEventId, (id) => {\n lastEventId = id;\n retries = 0;\n });\n } catch (err) {\n retries++;\n console.error(\n `[Channel] SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,\n err instanceof Error ? err.message : err,\n );\n\n if (retries >= CHANNEL_MAX_RETRIES) {\n console.error(\"[Channel] SSE connection exhausted, reporting error and exiting\");\n try {\n await fetch(`${tandemUrl}/api/channel-error`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n error: \"CHANNEL_CONNECT_FAILED\",\n message: `Channel shim lost connection after ${CHANNEL_MAX_RETRIES} retries.`,\n }),\n });\n } catch (reportErr) {\n console.error(\n \"[Channel] Could not report failure to server:\",\n reportErr instanceof Error ? reportErr.message : reportErr,\n );\n }\n process.exit(1);\n }\n\n await new Promise((r) => setTimeout(r, CHANNEL_RETRY_DELAY_MS));\n }\n }\n}\n\nasync function connectAndStream(\n mcp: Server,\n tandemUrl: string,\n lastEventId: string | undefined,\n onEventId: (id: string) => void,\n): Promise<void> {\n const headers: Record<string, string> = { Accept: \"text/event-stream\" };\n if (lastEventId) headers[\"Last-Event-ID\"] = lastEventId;\n\n const res = await fetch(`${tandemUrl}/api/events`, { headers });\n if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);\n if (!res.body) throw new Error(\"SSE endpoint returned no body\");\n\n const reader = res.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n\n // Debounced awareness: only send the latest status after a quiet period\n let awarenessTimer: ReturnType<typeof setTimeout> | null = null;\n let clearAwarenessTimer: ReturnType<typeof setTimeout> | null = null;\n let pendingAwareness: TandemEvent | null = null;\n const AWARENESS_CLEAR_MS = 3000; // Reset active state after 3s of no new events\n\n function clearAwareness(documentId?: string) {\n fetch(`${tandemUrl}/api/channel-awareness`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n documentId: documentId ?? null,\n status: \"idle\",\n active: false,\n }),\n }).catch(() => {});\n }\n\n function flushAwareness() {\n if (!pendingAwareness) return;\n const event = pendingAwareness;\n pendingAwareness = null;\n fetch(`${tandemUrl}/api/channel-awareness`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n documentId: event.documentId,\n status: `processing: ${event.type}`,\n active: true,\n }),\n }).catch((err) => {\n console.error(\"[Channel] Awareness update failed:\", err instanceof Error ? err.message : err);\n });\n\n // Auto-clear after timeout so the indicator doesn't stick\n if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);\n clearAwarenessTimer = setTimeout(() => clearAwareness(event.documentId), AWARENESS_CLEAR_MS);\n }\n\n function scheduleAwareness(event: TandemEvent) {\n pendingAwareness = event;\n if (awarenessTimer) clearTimeout(awarenessTimer);\n awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);\n }\n\n // Debounced selection: coalesce rapid selection changes, skip cleared selections\n let selectionTimer: ReturnType<typeof setTimeout> | null = null;\n let pendingSelection: { event: TandemEvent; eventId?: string } | null = null;\n let transportBroken = false;\n\n async function flushSelection() {\n if (!pendingSelection) return;\n const { event, eventId } = pendingSelection;\n pendingSelection = null;\n if (eventId) onEventId(eventId);\n try {\n await mcp.notification({\n method: \"notifications/claude/channel\",\n params: {\n content: formatEventContent(event),\n meta: formatEventMeta(event),\n },\n });\n } catch (err) {\n console.error(\"[Channel] MCP notification failed (transport broken?):\", err);\n transportBroken = true;\n return;\n }\n scheduleAwareness(event);\n }\n\n function isSelectionCleared(event: TandemEvent): boolean {\n const p = event.payload as { from?: number; to?: number; selectedText?: string } | undefined;\n return !p || (p.from === p.to && !p.selectedText);\n }\n\n while (true) {\n if (transportBroken) throw new Error(\"MCP transport broken (detected in debounced flush)\");\n const { done, value } = await reader.read();\n if (done) throw new Error(\"SSE stream ended\");\n\n buffer += decoder.decode(value, { stream: true });\n\n let boundary: number;\n while ((boundary = buffer.indexOf(\"\\n\\n\")) !== -1) {\n const frame = buffer.slice(0, boundary);\n buffer = buffer.slice(boundary + 2);\n\n if (frame.startsWith(\":\")) continue;\n\n let eventId: string | undefined;\n let data: string | undefined;\n\n for (const line of frame.split(\"\\n\")) {\n if (line.startsWith(\"id: \")) eventId = line.slice(4);\n else if (line.startsWith(\"data: \")) data = line.slice(6);\n }\n\n if (!data) continue;\n\n let event: TandemEvent | null;\n try {\n event = parseTandemEvent(JSON.parse(data));\n } catch {\n console.error(\"[Channel] Malformed SSE event data (skipping):\", data.slice(0, 200));\n continue;\n }\n if (!event) {\n console.error(\"[Channel] Received invalid SSE event, skipping\");\n continue;\n }\n\n // Selection events: drop cleared selections, debounce the rest\n if (event.type === \"selection:changed\") {\n if (eventId) onEventId(eventId);\n if (isSelectionCleared(event)) continue; // silently drop\n pendingSelection = { event, eventId };\n if (selectionTimer) clearTimeout(selectionTimer);\n selectionTimer = setTimeout(flushSelection, SELECTION_DEBOUNCE_MS);\n continue;\n }\n\n if (eventId) onEventId(eventId);\n\n try {\n await mcp.notification({\n method: \"notifications/claude/channel\",\n params: {\n content: formatEventContent(event),\n meta: formatEventMeta(event),\n },\n });\n } catch (err) {\n console.error(\"[Channel] MCP notification failed (transport broken?):\", err);\n throw err;\n }\n\n scheduleAwareness(event);\n }\n }\n}\n"],"mappings":"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","names":["mcp"]}
|
package/dist/cli/index.js
CHANGED
|
@@ -337,7 +337,7 @@ var init_start = __esm({
|
|
|
337
337
|
|
|
338
338
|
// src/cli/index.ts
|
|
339
339
|
import updateNotifier from "update-notifier";
|
|
340
|
-
var version = true ? "0.2.
|
|
340
|
+
var version = true ? "0.2.12" : "0.0.0-dev";
|
|
341
341
|
updateNotifier({ pkg: { name: "tandem-editor", version } }).notify();
|
|
342
342
|
var args = process.argv.slice(2);
|
|
343
343
|
if (args.includes("--help") || args.includes("-h")) {
|