tandem-editor 0.5.0 → 0.6.1
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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +27 -0
- package/CHANGELOG.md +61 -0
- package/README.md +27 -0
- package/dist/channel/index.js +156 -143
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +718 -133
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/index-mo5ZOPfU.js +349 -0
- package/dist/client/index.html +63 -2
- package/dist/monitor/index.js +4570 -0
- package/dist/monitor/index.js.map +1 -0
- package/dist/server/index.js +152 -221
- package/dist/server/index.js.map +1 -1
- package/package.json +5 -3
- package/skills/tandem/SKILL.md +93 -0
- package/dist/client/assets/index-BS8jwldm.js +0 -345
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tandem-editor",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Tandem"
|
|
5
|
+
},
|
|
6
|
+
"metadata": {
|
|
7
|
+
"description": "Tandem — collaborative AI-human document editor"
|
|
8
|
+
},
|
|
9
|
+
"plugins": [
|
|
10
|
+
{
|
|
11
|
+
"name": "tandem",
|
|
12
|
+
"source": {
|
|
13
|
+
"source": "github",
|
|
14
|
+
"repo": "bloknayrb/tandem"
|
|
15
|
+
},
|
|
16
|
+
"description": "Edit and iterate on documents with Claude — no copy-paste, real-time push via plugin monitor"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tandem",
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "Edit and iterate on documents with Claude — no copy-paste, real-time push via plugin monitor",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Tandem"
|
|
7
|
+
},
|
|
8
|
+
"repository": "https://github.com/bloknayrb/tandem",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": ["editor", "collaborative", "mcp", "claude"],
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"tandem": {
|
|
13
|
+
"command": "npx",
|
|
14
|
+
"args": ["-y", "tandem-editor", "mcp-stdio"],
|
|
15
|
+
"env": {
|
|
16
|
+
"TANDEM_URL": "http://localhost:3479"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"tandem-channel": {
|
|
20
|
+
"command": "npx",
|
|
21
|
+
"args": ["-y", "tandem-editor", "channel"],
|
|
22
|
+
"env": {
|
|
23
|
+
"TANDEM_URL": "http://localhost:3479"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,67 @@ All notable changes to Tandem will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.6.1] - 2026-04-15
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Tauri desktop app fails to start** — `src/cli/skill-content.ts` reads `skills/tandem/SKILL.md` at module-init via `readFileSync`, and `src/server/mcp/api-routes.ts` transitively imports it, so the bundled sidecar server crashed on startup with `ENOENT: skills/tandem/SKILL.md`. The `skills/` directory was never declared in `src-tauri/tauri.conf.json` bundle resources (latent since the v0.5.1 refactor to read SKILL.md at runtime). Add `"../skills/": "skills/"` so the sidecar's relative path resolution matches the npm-install layout. npm-published 0.6.0 was unaffected because `skills/` ships in the npm tarball via `package.json` `files`.
|
|
15
|
+
|
|
16
|
+
## [0.6.0] - 2026-04-15
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **Plugin bridge to Cowork** — new `tandem mcp-stdio` subcommand is a stdio ↔ HTTP JSON-RPC proxy so Claude Desktop's plugin loader surfaces the full `tandem_*` tool surface into Cowork VM sessions. Verified empirically that plugin-loaded HTTP MCP entries don't bridge to Cowork but plugin-loaded stdio entries do. The plugin's `.claude-plugin/plugin.json` now declares stdio entries for both `tandem` (proxy) and `tandem-channel` (existing shim re-exposed as `tandem channel` subcommand), invoked via `npx -y tandem-editor …` so the plugin cache never needs dev dependencies. Both subcommands share a strict preflight in `src/cli/preflight.ts` that fails fast with a single clear message when the Tandem server isn't running on `localhost:3479`.
|
|
21
|
+
- `tandem channel` CLI subcommand — npm-delivered entry for the plugin's `tandem-channel` MCP server; shares runtime with the standalone `src/channel/index.ts` binary via the new `src/channel/run.ts` extraction.
|
|
22
|
+
- **Settings expansion** — Settings popover grows from layout/dwell/authorship into a fuller preferences surface:
|
|
23
|
+
- Ctrl+, / Cmd+, hotkey (AZERTY/QWERTZ/IME-safe, survives non-QWERTY layouts)
|
|
24
|
+
- Display Name field, synced live with the StatusBar via a shared `useUserName` hook
|
|
25
|
+
- Reduce Motion toggle (JS-gates all autoscroll paths; defaults to `prefers-reduced-motion`)
|
|
26
|
+
- Text Size S/M/L for editor reading density (browser zoom remains the WCAG 1.4.4 path)
|
|
27
|
+
- Theme Light/Dark/System (CSS custom-property token system on `<html data-theme>` with `forced-colors` support for Windows High Contrast)
|
|
28
|
+
- Tier 0 accessibility prerequisites on SettingsPopover: `role="dialog"` + `aria-modal`, focus trap, Escape-to-close, pointerdown outside-dismiss, radiogroup semantics, 24×24 hit targets, focus-return on close
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Repo's project-level `.mcp.json` renamed to `.mcp.json.example` and gitignored so it no longer ships inside plugin installs. The plugin's own `.claude-plugin/plugin.json` is authoritative for MCP wiring. Developers who clone the repo should copy the example to `.mcp.json` (gitignored) if they want Claude Code to auto-connect locally.
|
|
33
|
+
- Settings heading renamed "Layout Settings" → "Settings"
|
|
34
|
+
- Settings popover hardcoded hex values swapped to CSS tokens (remaining components will migrate in a follow-up)
|
|
35
|
+
- `shutdownForTests` renamed to `shutdownMonitor` (test-only alias kept for backward compatibility)
|
|
36
|
+
- `refreshMode` IIFE now wrapped in an outer `.catch` to keep future synchronous throws off the hot path
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **Monitor preserves last-known `documentId`** — doc-less events (e.g. `chat:message`) no longer blank out the tracked document, so the shutdown awareness clear always targets a valid document.
|
|
41
|
+
- **Monitor exits 1 on shutdown awareness failure** — if the final `clearAwareness` POST fails during SIGINT/SIGTERM, the monitor exits with a non-zero status rather than silently succeeding.
|
|
42
|
+
- **`/api/setup` returns accurate status codes** — 207 on partial failure (some targets configured, some failed) and 500 on total failure, instead of always returning 200.
|
|
43
|
+
- **Checkpoint after stdout write** — `lastEventId` is only advanced after `process.stdout.write` returns, so EPIPE on a closed pipe no longer silently skips an event on reconnect.
|
|
44
|
+
- **Async EPIPE surfaces as exit(1)** — `process.stdout.on('error')` listener now catches asynchronous EPIPE (plugin host closes pipe mid-stream); monitor exits 1 instead of silently advancing `lastEventId` past lost events.
|
|
45
|
+
- **Defensive exit on monitor fallthrough** — the retry loop exits 1 if it ever terminates without hitting the explicit exhaustion path.
|
|
46
|
+
|
|
47
|
+
## [0.5.1] - 2026-04-13
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **Claude Code plugin support** — monitor-based event push (`src/monitor/index.ts`) gives real-time notifications without polling or the channel shim. Install via `claude plugin marketplace add bloknayrb/tandem`.
|
|
52
|
+
- **`--with-channel-shim` opt-in** — `tandem setup --with-channel-shim` writes the legacy `tandem-channel` MCP entry for setups that can't install the plugin.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- `tandem setup` no longer writes the `tandem-channel` MCP entry by default — running the plugin and the shim simultaneously produces duplicate event notifications. The shim is now opt-in only via `--with-channel-shim`.
|
|
57
|
+
|
|
58
|
+
### Fixed
|
|
59
|
+
|
|
60
|
+
- **Windows update failure** — sidecar is now killed before the NSIS installer runs, preventing "Error opening file for writing: node-sidecar.exe" during updates
|
|
61
|
+
- **Mode check fails closed** — `/api/mode` errors now fall back to "solo" at startup (privacy signal, not a permissive default) while the hot-path background refresh keeps the last known good value to avoid mid-session suppression.
|
|
62
|
+
- **Retry counter resets on stable uptime** — retry count now resets only after 60s of continuous uptime, not on every delivered event; prevents infinite reconnect loops when the server crashes after each event.
|
|
63
|
+
- **Exponential backoff on reconnect** — monitor reconnects use 2s/4s/8s/16s/30s backoff instead of a fixed 2s delay.
|
|
64
|
+
- **SIGINT/SIGTERM clears awareness** — monitor posts a final `clearAwareness` before exit so the "Claude is active" indicator doesn't hang in the browser.
|
|
65
|
+
- **Per-route fetch timeouts** — `AbortSignal.timeout` enforces budgets per route (connect 10s, mode 2s, awareness 5s, error report 3s) to prevent hung SSE connects or mode lookups from stalling the monitor.
|
|
66
|
+
- **SSE parse errors don't advance `lastEventId`** — JSON parse failures and schema validation errors are logged with event ID + frame tail but do not advance `lastEventId`, so bad events are re-delivered on reconnect rather than silently dropped.
|
|
67
|
+
- **SKILL.md corrected** — `question` annotation guidance now uses `type === 'comment' && directedAt === 'claude' && author === 'user'`; all 5 highlight colors listed (yellow, red, green, blue, purple).
|
|
68
|
+
|
|
8
69
|
## [0.5.0] - 2026-04-13
|
|
9
70
|
|
|
10
71
|
### Added
|
package/README.md
CHANGED
|
@@ -34,6 +34,33 @@ tandem # starts server + opens browser
|
|
|
34
34
|
|
|
35
35
|
`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`).
|
|
36
36
|
|
|
37
|
+
### Quickstart: Claude Code plugin (recommended)
|
|
38
|
+
|
|
39
|
+
Install the plugin to expose Tandem's tools and real-time event stream into Claude Desktop chats **and** Cowork VM sessions:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
claude plugin marketplace add bloknayrb/tandem
|
|
43
|
+
claude plugin install tandem@tandem-editor
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Tandem must be running on the host before the plugin can do anything.** The plugin spawns two stdio MCP processes (`tandem mcp-stdio` and `tandem channel`) that proxy to `http://localhost:3479`. If the server isn't up they fail fast and log "Tandem server not reachable at …". Start the Tauri desktop app or run `tandem start` on the host first, then open Claude.
|
|
47
|
+
|
|
48
|
+
### Legacy stdio channel shim
|
|
49
|
+
|
|
50
|
+
If you can't install the plugin, use the older channel shim:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
tandem setup --with-channel-shim
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This writes a `tandem-channel` entry to your Claude Code MCP config. Start Claude Code with:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
claude --dangerously-load-development-channels server:tandem-channel
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Don't combine this with the plugin — both subscribe to `/api/events` and you'll get duplicate notifications for every event.
|
|
63
|
+
|
|
37
64
|
### Connect Claude Code
|
|
38
65
|
|
|
39
66
|
For the full Tandem experience, start Claude Code with the **channel push** flag:
|
package/dist/channel/index.js
CHANGED
|
@@ -6799,7 +6799,7 @@ var require_dist = __commonJS({
|
|
|
6799
6799
|
}
|
|
6800
6800
|
});
|
|
6801
6801
|
|
|
6802
|
-
// src/channel/
|
|
6802
|
+
// src/channel/run.ts
|
|
6803
6803
|
import { createConnection } from "net";
|
|
6804
6804
|
|
|
6805
6805
|
// node_modules/zod/v3/external.js
|
|
@@ -17915,6 +17915,17 @@ var SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
|
|
|
17915
17915
|
var CHANNEL_MAX_RETRIES = 5;
|
|
17916
17916
|
var CHANNEL_RETRY_DELAY_MS = 2e3;
|
|
17917
17917
|
|
|
17918
|
+
// src/shared/cli-runtime.ts
|
|
17919
|
+
function redirectConsoleToStderr() {
|
|
17920
|
+
console.log = console.error;
|
|
17921
|
+
console.warn = console.error;
|
|
17922
|
+
console.info = console.error;
|
|
17923
|
+
}
|
|
17924
|
+
function resolveTandemUrl(override) {
|
|
17925
|
+
const raw = override ?? process.env.TANDEM_URL ?? `http://localhost:${DEFAULT_MCP_PORT}`;
|
|
17926
|
+
return raw.replace(/\/$/, "");
|
|
17927
|
+
}
|
|
17928
|
+
|
|
17918
17929
|
// src/server/events/types.ts
|
|
17919
17930
|
var VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
17920
17931
|
"annotation:created",
|
|
@@ -18013,12 +18024,12 @@ function formatEventMeta(event) {
|
|
|
18013
18024
|
// src/channel/event-bridge.ts
|
|
18014
18025
|
var AWARENESS_DEBOUNCE_MS = 500;
|
|
18015
18026
|
var MODE_CACHE_TTL_MS = 2e3;
|
|
18016
|
-
async function startEventBridge(
|
|
18027
|
+
async function startEventBridge(mcp, tandemUrl) {
|
|
18017
18028
|
let retries = 0;
|
|
18018
18029
|
let lastEventId;
|
|
18019
18030
|
while (retries < CHANNEL_MAX_RETRIES) {
|
|
18020
18031
|
try {
|
|
18021
|
-
await connectAndStream(
|
|
18032
|
+
await connectAndStream(mcp, tandemUrl, lastEventId, (id) => {
|
|
18022
18033
|
lastEventId = id;
|
|
18023
18034
|
retries = 0;
|
|
18024
18035
|
});
|
|
@@ -18051,7 +18062,7 @@ async function startEventBridge(mcp2, tandemUrl) {
|
|
|
18051
18062
|
}
|
|
18052
18063
|
}
|
|
18053
18064
|
}
|
|
18054
|
-
async function connectAndStream(
|
|
18065
|
+
async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
18055
18066
|
const headers = { Accept: "text/event-stream" };
|
|
18056
18067
|
if (lastEventId) headers["Last-Event-ID"] = lastEventId;
|
|
18057
18068
|
const res = await fetch(`${tandemUrl}/api/events`, { headers });
|
|
@@ -18136,7 +18147,7 @@ async function connectAndStream(mcp2, tandemUrl, lastEventId, onEventId) {
|
|
|
18136
18147
|
}
|
|
18137
18148
|
if (eventId) onEventId(eventId);
|
|
18138
18149
|
try {
|
|
18139
|
-
await
|
|
18150
|
+
await mcp.notification({
|
|
18140
18151
|
method: "notifications/claude/channel",
|
|
18141
18152
|
params: {
|
|
18142
18153
|
content: formatEventContent(event),
|
|
@@ -18175,11 +18186,143 @@ async function getCachedMode(tandemUrl) {
|
|
|
18175
18186
|
return cachedMode;
|
|
18176
18187
|
}
|
|
18177
18188
|
|
|
18178
|
-
// src/channel/
|
|
18179
|
-
|
|
18180
|
-
|
|
18181
|
-
|
|
18182
|
-
|
|
18189
|
+
// src/channel/run.ts
|
|
18190
|
+
async function runChannel(opts = {}) {
|
|
18191
|
+
redirectConsoleToStderr();
|
|
18192
|
+
const tandemUrl = resolveTandemUrl();
|
|
18193
|
+
const mcp = new Server(
|
|
18194
|
+
{ name: "tandem-channel", version: "0.1.0" },
|
|
18195
|
+
{
|
|
18196
|
+
capabilities: {
|
|
18197
|
+
experimental: {
|
|
18198
|
+
"claude/channel": {},
|
|
18199
|
+
"claude/channel/permission": {}
|
|
18200
|
+
},
|
|
18201
|
+
tools: {}
|
|
18202
|
+
},
|
|
18203
|
+
instructions: [
|
|
18204
|
+
'Events from Tandem arrive as <channel source="tandem-channel" event_type="..." document_id="...">.',
|
|
18205
|
+
"These are real-time push notifications of user actions in the collaborative document editor.",
|
|
18206
|
+
"Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,",
|
|
18207
|
+
"chat:message, document:opened, document:closed, document:switched.",
|
|
18208
|
+
"Chat messages may include a 'selection' field with buffered selection context.",
|
|
18209
|
+
"Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.",
|
|
18210
|
+
"Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
|
|
18211
|
+
"Do not reply to non-chat events \u2014 just act on them using tools.",
|
|
18212
|
+
"If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
|
|
18213
|
+
].join(" ")
|
|
18214
|
+
}
|
|
18215
|
+
);
|
|
18216
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
18217
|
+
tools: [
|
|
18218
|
+
{
|
|
18219
|
+
name: "tandem_reply",
|
|
18220
|
+
description: "Reply to a chat message in Tandem",
|
|
18221
|
+
inputSchema: {
|
|
18222
|
+
type: "object",
|
|
18223
|
+
properties: {
|
|
18224
|
+
text: { type: "string", description: "The reply message" },
|
|
18225
|
+
documentId: {
|
|
18226
|
+
type: "string",
|
|
18227
|
+
description: "Document ID from the channel event (optional)"
|
|
18228
|
+
},
|
|
18229
|
+
replyTo: {
|
|
18230
|
+
type: "string",
|
|
18231
|
+
description: "Message ID being replied to (optional)"
|
|
18232
|
+
}
|
|
18233
|
+
},
|
|
18234
|
+
required: ["text"]
|
|
18235
|
+
}
|
|
18236
|
+
}
|
|
18237
|
+
]
|
|
18238
|
+
}));
|
|
18239
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
18240
|
+
if (req.params.name === "tandem_reply") {
|
|
18241
|
+
const args = req.params.arguments;
|
|
18242
|
+
try {
|
|
18243
|
+
const res = await fetch(`${tandemUrl}/api/channel-reply`, {
|
|
18244
|
+
method: "POST",
|
|
18245
|
+
headers: { "Content-Type": "application/json" },
|
|
18246
|
+
body: JSON.stringify(args)
|
|
18247
|
+
});
|
|
18248
|
+
let data;
|
|
18249
|
+
try {
|
|
18250
|
+
data = await res.json();
|
|
18251
|
+
} catch {
|
|
18252
|
+
data = { message: "Non-JSON response" };
|
|
18253
|
+
}
|
|
18254
|
+
if (!res.ok) {
|
|
18255
|
+
return {
|
|
18256
|
+
content: [
|
|
18257
|
+
{
|
|
18258
|
+
type: "text",
|
|
18259
|
+
text: `Reply failed (${res.status}): ${JSON.stringify(data)}`
|
|
18260
|
+
}
|
|
18261
|
+
],
|
|
18262
|
+
isError: true
|
|
18263
|
+
};
|
|
18264
|
+
}
|
|
18265
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
18266
|
+
} catch (err) {
|
|
18267
|
+
return {
|
|
18268
|
+
content: [
|
|
18269
|
+
{
|
|
18270
|
+
type: "text",
|
|
18271
|
+
text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
|
|
18272
|
+
}
|
|
18273
|
+
],
|
|
18274
|
+
isError: true
|
|
18275
|
+
};
|
|
18276
|
+
}
|
|
18277
|
+
}
|
|
18278
|
+
throw new Error(`Unknown tool: ${req.params.name}`);
|
|
18279
|
+
});
|
|
18280
|
+
const PermissionRequestSchema = external_exports.object({
|
|
18281
|
+
method: external_exports.literal("notifications/claude/channel/permission_request"),
|
|
18282
|
+
params: external_exports.object({
|
|
18283
|
+
request_id: external_exports.string(),
|
|
18284
|
+
tool_name: external_exports.string(),
|
|
18285
|
+
description: external_exports.string(),
|
|
18286
|
+
input_preview: external_exports.string()
|
|
18287
|
+
})
|
|
18288
|
+
});
|
|
18289
|
+
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
|
|
18290
|
+
try {
|
|
18291
|
+
const res = await fetch(`${tandemUrl}/api/channel-permission`, {
|
|
18292
|
+
method: "POST",
|
|
18293
|
+
headers: { "Content-Type": "application/json" },
|
|
18294
|
+
body: JSON.stringify({
|
|
18295
|
+
requestId: params.request_id,
|
|
18296
|
+
toolName: params.tool_name,
|
|
18297
|
+
description: params.description,
|
|
18298
|
+
inputPreview: params.input_preview
|
|
18299
|
+
})
|
|
18300
|
+
});
|
|
18301
|
+
if (!res.ok) {
|
|
18302
|
+
console.error(
|
|
18303
|
+
`[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
|
|
18304
|
+
);
|
|
18305
|
+
}
|
|
18306
|
+
} catch (err) {
|
|
18307
|
+
console.error("[Channel] Failed to forward permission request:", err);
|
|
18308
|
+
}
|
|
18309
|
+
});
|
|
18310
|
+
console.error(`[Channel] Tandem channel shim starting (server: ${tandemUrl})`);
|
|
18311
|
+
if (!opts.skipReachabilityLog) {
|
|
18312
|
+
const reachable = await checkServerReachable(tandemUrl);
|
|
18313
|
+
if (!reachable) {
|
|
18314
|
+
console.error(`[Channel] Cannot reach Tandem server at ${tandemUrl}`);
|
|
18315
|
+
console.error("[Channel] Start it with: tandem start");
|
|
18316
|
+
}
|
|
18317
|
+
}
|
|
18318
|
+
const transport = new StdioServerTransport();
|
|
18319
|
+
await mcp.connect(transport);
|
|
18320
|
+
console.error("[Channel] Connected to Claude Code via stdio");
|
|
18321
|
+
startEventBridge(mcp, tandemUrl).catch((err) => {
|
|
18322
|
+
console.error("[Channel] Event bridge failed unexpectedly:", err);
|
|
18323
|
+
process.exit(1);
|
|
18324
|
+
});
|
|
18325
|
+
}
|
|
18183
18326
|
async function checkServerReachable(url, timeoutMs = 2e3) {
|
|
18184
18327
|
let parsed;
|
|
18185
18328
|
try {
|
|
@@ -18208,139 +18351,9 @@ async function checkServerReachable(url, timeoutMs = 2e3) {
|
|
|
18208
18351
|
});
|
|
18209
18352
|
});
|
|
18210
18353
|
}
|
|
18211
|
-
|
|
18212
|
-
|
|
18213
|
-
|
|
18214
|
-
capabilities: {
|
|
18215
|
-
experimental: {
|
|
18216
|
-
"claude/channel": {},
|
|
18217
|
-
"claude/channel/permission": {}
|
|
18218
|
-
},
|
|
18219
|
-
tools: {}
|
|
18220
|
-
},
|
|
18221
|
-
instructions: [
|
|
18222
|
-
'Events from Tandem arrive as <channel source="tandem-channel" event_type="..." document_id="...">.',
|
|
18223
|
-
"These are real-time push notifications of user actions in the collaborative document editor.",
|
|
18224
|
-
"Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,",
|
|
18225
|
-
"chat:message, document:opened, document:closed, document:switched.",
|
|
18226
|
-
"Chat messages may include a 'selection' field with buffered selection context.",
|
|
18227
|
-
"Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.",
|
|
18228
|
-
"Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
|
|
18229
|
-
"Do not reply to non-chat events \u2014 just act on them using tools.",
|
|
18230
|
-
"If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
|
|
18231
|
-
].join(" ")
|
|
18232
|
-
}
|
|
18233
|
-
);
|
|
18234
|
-
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
18235
|
-
tools: [
|
|
18236
|
-
{
|
|
18237
|
-
name: "tandem_reply",
|
|
18238
|
-
description: "Reply to a chat message in Tandem",
|
|
18239
|
-
inputSchema: {
|
|
18240
|
-
type: "object",
|
|
18241
|
-
properties: {
|
|
18242
|
-
text: { type: "string", description: "The reply message" },
|
|
18243
|
-
documentId: {
|
|
18244
|
-
type: "string",
|
|
18245
|
-
description: "Document ID from the channel event (optional)"
|
|
18246
|
-
},
|
|
18247
|
-
replyTo: {
|
|
18248
|
-
type: "string",
|
|
18249
|
-
description: "Message ID being replied to (optional)"
|
|
18250
|
-
}
|
|
18251
|
-
},
|
|
18252
|
-
required: ["text"]
|
|
18253
|
-
}
|
|
18254
|
-
}
|
|
18255
|
-
]
|
|
18256
|
-
}));
|
|
18257
|
-
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
18258
|
-
if (req.params.name === "tandem_reply") {
|
|
18259
|
-
const args = req.params.arguments;
|
|
18260
|
-
try {
|
|
18261
|
-
const res = await fetch(`${TANDEM_URL}/api/channel-reply`, {
|
|
18262
|
-
method: "POST",
|
|
18263
|
-
headers: { "Content-Type": "application/json" },
|
|
18264
|
-
body: JSON.stringify(args)
|
|
18265
|
-
});
|
|
18266
|
-
let data;
|
|
18267
|
-
try {
|
|
18268
|
-
data = await res.json();
|
|
18269
|
-
} catch {
|
|
18270
|
-
data = { message: "Non-JSON response" };
|
|
18271
|
-
}
|
|
18272
|
-
if (!res.ok) {
|
|
18273
|
-
return {
|
|
18274
|
-
content: [
|
|
18275
|
-
{
|
|
18276
|
-
type: "text",
|
|
18277
|
-
text: `Reply failed (${res.status}): ${JSON.stringify(data)}`
|
|
18278
|
-
}
|
|
18279
|
-
],
|
|
18280
|
-
isError: true
|
|
18281
|
-
};
|
|
18282
|
-
}
|
|
18283
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
18284
|
-
} catch (err) {
|
|
18285
|
-
return {
|
|
18286
|
-
content: [
|
|
18287
|
-
{
|
|
18288
|
-
type: "text",
|
|
18289
|
-
text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
|
|
18290
|
-
}
|
|
18291
|
-
],
|
|
18292
|
-
isError: true
|
|
18293
|
-
};
|
|
18294
|
-
}
|
|
18295
|
-
}
|
|
18296
|
-
throw new Error(`Unknown tool: ${req.params.name}`);
|
|
18297
|
-
});
|
|
18298
|
-
var PermissionRequestSchema = external_exports.object({
|
|
18299
|
-
method: external_exports.literal("notifications/claude/channel/permission_request"),
|
|
18300
|
-
params: external_exports.object({
|
|
18301
|
-
request_id: external_exports.string(),
|
|
18302
|
-
tool_name: external_exports.string(),
|
|
18303
|
-
description: external_exports.string(),
|
|
18304
|
-
input_preview: external_exports.string()
|
|
18305
|
-
})
|
|
18306
|
-
});
|
|
18307
|
-
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
|
|
18308
|
-
try {
|
|
18309
|
-
const res = await fetch(`${TANDEM_URL}/api/channel-permission`, {
|
|
18310
|
-
method: "POST",
|
|
18311
|
-
headers: { "Content-Type": "application/json" },
|
|
18312
|
-
body: JSON.stringify({
|
|
18313
|
-
requestId: params.request_id,
|
|
18314
|
-
toolName: params.tool_name,
|
|
18315
|
-
description: params.description,
|
|
18316
|
-
inputPreview: params.input_preview
|
|
18317
|
-
})
|
|
18318
|
-
});
|
|
18319
|
-
if (!res.ok) {
|
|
18320
|
-
console.error(
|
|
18321
|
-
`[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
|
|
18322
|
-
);
|
|
18323
|
-
}
|
|
18324
|
-
} catch (err) {
|
|
18325
|
-
console.error("[Channel] Failed to forward permission request:", err);
|
|
18326
|
-
}
|
|
18327
|
-
});
|
|
18328
|
-
async function main() {
|
|
18329
|
-
console.error(`[Channel] Tandem channel shim starting (server: ${TANDEM_URL})`);
|
|
18330
|
-
const reachable = await checkServerReachable(TANDEM_URL);
|
|
18331
|
-
if (!reachable) {
|
|
18332
|
-
console.error(`[Channel] Cannot reach Tandem server at ${TANDEM_URL}`);
|
|
18333
|
-
console.error("[Channel] Start it with: npm run dev:standalone");
|
|
18334
|
-
}
|
|
18335
|
-
const transport = new StdioServerTransport();
|
|
18336
|
-
await mcp.connect(transport);
|
|
18337
|
-
console.error("[Channel] Connected to Claude Code via stdio");
|
|
18338
|
-
startEventBridge(mcp, TANDEM_URL).catch((err) => {
|
|
18339
|
-
console.error("[Channel] Event bridge failed unexpectedly:", err);
|
|
18340
|
-
process.exit(1);
|
|
18341
|
-
});
|
|
18342
|
-
}
|
|
18343
|
-
main().catch((err) => {
|
|
18354
|
+
|
|
18355
|
+
// src/channel/index.ts
|
|
18356
|
+
runChannel().catch((err) => {
|
|
18344
18357
|
console.error("[Channel] Fatal error:", err);
|
|
18345
18358
|
process.exit(1);
|
|
18346
18359
|
});
|