tandem-editor 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +27 -0
- package/CHANGELOG.md +80 -0
- package/README.md +68 -23
- package/dist/channel/index.js +171 -190
- 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/assets/webview-0tvvWtyc.js +1 -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 +1042 -801
- package/dist/server/index.js.map +1 -1
- package/package.json +13 -8
- package/sample/welcome.md +3 -3
- package/skills/tandem/SKILL.md +93 -0
- package/dist/client/assets/index-D6wQrQ7U.js +0 -308
package/dist/cli/index.js
CHANGED
|
@@ -10,7 +10,7 @@ var __export = (target, all) => {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
// src/shared/constants.ts
|
|
13
|
-
var DEFAULT_MCP_PORT, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE;
|
|
13
|
+
var DEFAULT_MCP_PORT, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS;
|
|
14
14
|
var init_constants = __esm({
|
|
15
15
|
"src/shared/constants.ts"() {
|
|
16
16
|
"use strict";
|
|
@@ -19,108 +19,22 @@ var init_constants = __esm({
|
|
|
19
19
|
MAX_WS_PAYLOAD = 10 * 1024 * 1024;
|
|
20
20
|
IDLE_TIMEOUT = 30 * 60 * 1e3;
|
|
21
21
|
SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
|
|
22
|
+
CHANNEL_MAX_RETRIES = 5;
|
|
23
|
+
CHANNEL_RETRY_DELAY_MS = 2e3;
|
|
22
24
|
}
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
// src/cli/skill-content.ts
|
|
26
|
-
|
|
28
|
+
import { readFileSync } from "fs";
|
|
29
|
+
import { dirname, resolve } from "path";
|
|
30
|
+
import { fileURLToPath } from "url";
|
|
31
|
+
var __dirname, SKILL_PATH, SKILL_CONTENT;
|
|
27
32
|
var init_skill_content = __esm({
|
|
28
33
|
"src/cli/skill-content.ts"() {
|
|
29
34
|
"use strict";
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Use when tandem_* MCP tools are available, the user asks about Tandem
|
|
34
|
-
document editing, or collaborative document review. Provides workflow
|
|
35
|
-
guidance, annotation strategy, and tool usage patterns for the Tandem
|
|
36
|
-
collaborative editor.
|
|
37
|
-
---
|
|
38
|
-
|
|
39
|
-
# Tandem \u2014 Collaborative Document Editor
|
|
40
|
-
|
|
41
|
-
Tandem lets you annotate and edit documents alongside the user in real time. The user sees your changes in a browser editor; you interact via the tandem_* MCP tool suite.
|
|
42
|
-
|
|
43
|
-
## Hard Rules
|
|
44
|
-
|
|
45
|
-
These prevent the most common failures. Follow them always.
|
|
46
|
-
|
|
47
|
-
1. **Resolve before mutating.** Call \`tandem_resolveRange\` (or \`tandem_search\`) to get offsets before calling \`tandem_edit\`, \`tandem_highlight\`, \`tandem_comment\`, \`tandem_suggest\`, or \`tandem_flag\`. Never compute offsets by counting characters in previously-read text \u2014 they go stale when the user edits.
|
|
48
|
-
2. **Pass \`textSnapshot\`.** Include the matched text as \`textSnapshot\` on mutations and annotations. If the text moved, the server returns \`RANGE_MOVED\` with relocated coordinates instead of corrupting the document.
|
|
49
|
-
3. **Use \`tandem_getTextContent\`, not \`tandem_getContent\`.** \`getContent\` returns ProseMirror JSON and burns tokens. Use \`getTextContent({ section: "Section Name" })\` for targeted reads. The \`section\` parameter is case-insensitive.
|
|
50
|
-
4. **\`tandem_edit\` cannot create paragraphs.** Newlines become literal characters. For multi-paragraph changes, use multiple \`tandem_edit\` calls or \`tandem_suggest\`.
|
|
51
|
-
5. **\`.docx\` files are read-only.** Use annotations instead of \`tandem_edit\`. Offer \`tandem_convertToMarkdown\` if the user wants an editable copy.
|
|
52
|
-
|
|
53
|
-
## Workflow
|
|
54
|
-
|
|
55
|
-
Standard review sequence:
|
|
56
|
-
|
|
57
|
-
1. \`tandem_status\` \u2014 check for already-open documents (sessions restore automatically)
|
|
58
|
-
2. \`tandem_getOutline\` \u2014 understand document structure
|
|
59
|
-
3. \`tandem_setStatus("Reviewing [section]...", { focusParagraph: N })\` \u2014 show progress (use \`index\` from outline)
|
|
60
|
-
4. \`tandem_getTextContent({ section: "..." })\` \u2014 read one section at a time
|
|
61
|
-
5. Annotate findings (see annotation guide below)
|
|
62
|
-
6. \`tandem_checkInbox\` \u2014 check for user messages and actions
|
|
63
|
-
7. Repeat steps 3-6 for each section
|
|
64
|
-
8. \`tandem_save\` \u2014 persist edits to disk when done
|
|
65
|
-
|
|
66
|
-
## Annotation Guide
|
|
67
|
-
|
|
68
|
-
Choose the right type for each finding:
|
|
69
|
-
|
|
70
|
-
- **\`tandem_highlight\`** \u2014 Visual marker with a short note. Colors: green (verified/good), red (problem), yellow (needs attention). Use when the finding is self-evident from the color and a brief note.
|
|
71
|
-
- **\`tandem_comment\`** \u2014 Observation requiring explanation. Use when you need more than one sentence to convey reasoning.
|
|
72
|
-
- **\`tandem_suggest\`** \u2014 Specific text replacement. **Prefer over comment when you can provide replacement text** \u2014 the user gets one-click accept/reject. Cannot create new paragraphs.
|
|
73
|
-
- **\`tandem_flag\`** \u2014 Factual errors, compliance risks, missing required content. Signals a blocking issue the user must address before the document ships.
|
|
74
|
-
|
|
75
|
-
**User-created types:** \`question\` annotation is created by users, not Claude. When you see a \`question\` in \`tandem_checkInbox\` or \`tandem_getAnnotations\`, respond with a \`tandem_comment\` on the same range or \`tandem_reply\` for conversational answers.
|
|
76
|
-
|
|
77
|
-
## Collaboration Mode
|
|
78
|
-
|
|
79
|
-
Check \`mode\` from \`tandem_status\` or \`tandem_checkInbox\` and adapt:
|
|
80
|
-
|
|
81
|
-
- **Tandem** (\`"tandem"\`, default) \u2014 Full collaboration. Annotate freely and react to selections and document changes.
|
|
82
|
-
- **Solo** (\`"solo"\`) \u2014 The user wants to write undisturbed. Only respond when the user sends a chat message. Do not proactively annotate or react to document activity.
|
|
83
|
-
|
|
84
|
-
## Reacting to Document Events
|
|
85
|
-
|
|
86
|
-
Selection events can reach you two ways. Over the real-time channel they arrive as notifications with \`meta.respond_via = "tandem_reply"\`. When polling via \`tandem_checkInbox\`, the current selection shows up under \`activity.selectedText\` (no \`meta\` field \u2014 that only exists on channel pushes). Either way, when the user holds a selection, briefly acknowledge what they highlighted via \`tandem_reply\` \u2014 don't annotate unless asked. Use \`tandem_reply\` for any document-context reaction (chat messages, selections, question annotations); reserve terminal output for non-document work the user explicitly requests. In Solo mode, hold reactions until the user sends a chat message.
|
|
87
|
-
|
|
88
|
-
## Collaboration Etiquette
|
|
89
|
-
|
|
90
|
-
- Check \`tandem_getActivity()\` before annotating near the user's cursor. If \`isTyping\` is true, wait for typing to stop before annotating that area.
|
|
91
|
-
- Use \`tandem_setStatus\` to show what you're working on \u2014 the user sees it in the browser status bar.
|
|
92
|
-
- **Call \`tandem_checkInbox\` every 2-3 tool calls**, not just at the end of a task. The real-time channel is often not connected; polling is the reliable path.
|
|
93
|
-
- Reply to chat messages with \`tandem_reply\`, not annotations.
|
|
94
|
-
|
|
95
|
-
## .docx Review Workflow
|
|
96
|
-
|
|
97
|
-
1. \`tandem_open\` \u2014 opens in read-only mode (\`readOnly: true\`)
|
|
98
|
-
2. \`tandem_getAnnotations({ author: "import" })\` \u2014 check for imported Word comments; read and act on them
|
|
99
|
-
3. Annotate with findings (highlight, comment, suggest, flag)
|
|
100
|
-
4. \`tandem_exportAnnotations\` \u2014 generate a review summary the user can share
|
|
101
|
-
5. If the user wants editable text, offer \`tandem_convertToMarkdown\`
|
|
102
|
-
|
|
103
|
-
## Error Recovery
|
|
104
|
-
|
|
105
|
-
- **\`RANGE_MOVED\`** \u2014 Text shifted since you read it. The response includes \`resolvedFrom\`/\`resolvedTo\` \u2014 use those coordinates for your next call.
|
|
106
|
-
- **\`RANGE_GONE\`** \u2014 The text was deleted. Re-read the section with \`tandem_getTextContent\` and re-assess.
|
|
107
|
-
- **\`INVALID_RANGE\`** \u2014 You hit heading markup (e.g., \`## \`). Target text content only, not the heading prefix.
|
|
108
|
-
- **\`FORMAT_ERROR\`** \u2014 Attempted \`tandem_edit\` on a read-only \`.docx\`. Use annotations instead.
|
|
109
|
-
|
|
110
|
-
## Session Handoff
|
|
111
|
-
|
|
112
|
-
When starting a new Claude session with Tandem already running:
|
|
113
|
-
|
|
114
|
-
1. \`tandem_status()\` \u2014 check \`openDocuments\` array for restored sessions
|
|
115
|
-
2. \`tandem_listDocuments()\` \u2014 see all open docs with details
|
|
116
|
-
3. \`tandem_getOutline()\` \u2014 orient on the active document
|
|
117
|
-
4. \`tandem_getAnnotations()\` \u2014 see what was already reviewed
|
|
118
|
-
5. Continue where the previous session left off
|
|
119
|
-
|
|
120
|
-
## Multi-Document
|
|
121
|
-
|
|
122
|
-
When multiple documents are open, always pass \`documentId\` explicitly \u2014 omitting it targets the active document, which may have changed since your last call. Use \`tandem_listDocuments\` to see what's available. Cross-reference by reading both docs via \`tandem_getTextContent({ documentId: "..." })\` and annotating the relevant one.
|
|
123
|
-
`;
|
|
35
|
+
__dirname = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
SKILL_PATH = resolve(__dirname, "../../skills/tandem/SKILL.md");
|
|
37
|
+
SKILL_CONTENT = readFileSync(SKILL_PATH, "utf-8");
|
|
124
38
|
}
|
|
125
39
|
});
|
|
126
40
|
|
|
@@ -131,26 +45,27 @@ __export(setup_exports, {
|
|
|
131
45
|
buildMcpEntries: () => buildMcpEntries,
|
|
132
46
|
detectTargets: () => detectTargets,
|
|
133
47
|
installSkill: () => installSkill,
|
|
134
|
-
runSetup: () => runSetup
|
|
48
|
+
runSetup: () => runSetup,
|
|
49
|
+
validateChannelShimPrereq: () => validateChannelShimPrereq
|
|
135
50
|
});
|
|
136
51
|
import { randomUUID } from "crypto";
|
|
137
|
-
import { existsSync, readFileSync } from "fs";
|
|
52
|
+
import { existsSync, readFileSync as readFileSync2 } from "fs";
|
|
138
53
|
import { copyFile, mkdir, rename, unlink, writeFile } from "fs/promises";
|
|
139
54
|
import { homedir } from "os";
|
|
140
|
-
import { dirname, join, resolve } from "path";
|
|
141
|
-
import { fileURLToPath } from "url";
|
|
142
|
-
function buildMcpEntries(channelPath,
|
|
143
|
-
|
|
144
|
-
tandem: {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
command: nodeBinary,
|
|
55
|
+
import { basename, dirname as dirname2, join, resolve as resolve2 } from "path";
|
|
56
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
57
|
+
function buildMcpEntries(channelPath, opts = {}) {
|
|
58
|
+
const entries = {
|
|
59
|
+
tandem: { type: "http", url: `${MCP_URL}/mcp` }
|
|
60
|
+
};
|
|
61
|
+
if (opts.withChannelShim) {
|
|
62
|
+
entries["tandem-channel"] = {
|
|
63
|
+
command: opts.nodeBinary ?? "node",
|
|
150
64
|
args: [channelPath],
|
|
151
65
|
env: { TANDEM_URL: MCP_URL }
|
|
152
|
-
}
|
|
153
|
-
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return entries;
|
|
154
69
|
}
|
|
155
70
|
function detectTargets(opts = {}) {
|
|
156
71
|
const home = opts.homeOverride ?? homedir();
|
|
@@ -181,7 +96,7 @@ function detectTargets(opts = {}) {
|
|
|
181
96
|
return targets;
|
|
182
97
|
}
|
|
183
98
|
async function atomicWrite(content, dest) {
|
|
184
|
-
const tmp = join(
|
|
99
|
+
const tmp = join(dirname2(dest), `.tandem-setup-${randomUUID()}.tmp`);
|
|
185
100
|
await writeFile(tmp, content, "utf-8");
|
|
186
101
|
try {
|
|
187
102
|
await rename(tmp, dest);
|
|
@@ -202,14 +117,23 @@ async function atomicWrite(content, dest) {
|
|
|
202
117
|
async function applyConfig(configPath, entries) {
|
|
203
118
|
let existing = {};
|
|
204
119
|
try {
|
|
205
|
-
existing = JSON.parse(
|
|
120
|
+
existing = JSON.parse(readFileSync2(configPath, "utf-8"));
|
|
206
121
|
} catch (err) {
|
|
207
122
|
const code = err.code;
|
|
208
123
|
if (code === "ENOENT") {
|
|
209
124
|
} else if (err instanceof SyntaxError) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
125
|
+
const backupPath = `${configPath}.broken-${Date.now()}`;
|
|
126
|
+
try {
|
|
127
|
+
await copyFile(configPath, backupPath);
|
|
128
|
+
console.error(
|
|
129
|
+
` Warning: ${configPath} contains malformed JSON \u2014 backed up to ${basename(backupPath)}, replacing with fresh config`
|
|
130
|
+
);
|
|
131
|
+
} catch (copyErr) {
|
|
132
|
+
console.error(
|
|
133
|
+
` Warning: ${configPath} contains malformed JSON and backup failed (${copyErr instanceof Error ? copyErr.message : copyErr}) \u2014 refusing to overwrite. Fix the JSON manually and rerun 'tandem setup'.`
|
|
134
|
+
);
|
|
135
|
+
throw copyErr;
|
|
136
|
+
}
|
|
213
137
|
} else {
|
|
214
138
|
throw err;
|
|
215
139
|
}
|
|
@@ -221,17 +145,27 @@ async function applyConfig(configPath, entries) {
|
|
|
221
145
|
...entries
|
|
222
146
|
}
|
|
223
147
|
};
|
|
224
|
-
await mkdir(
|
|
148
|
+
await mkdir(dirname2(configPath), { recursive: true });
|
|
225
149
|
await atomicWrite(JSON.stringify(updated, null, 2) + "\n", configPath);
|
|
226
150
|
}
|
|
227
151
|
async function installSkill(opts = {}) {
|
|
228
152
|
const home = opts.homeOverride ?? homedir();
|
|
229
153
|
const skillPath = join(home, ".claude", "skills", "tandem", "SKILL.md");
|
|
230
|
-
await mkdir(
|
|
154
|
+
await mkdir(dirname2(skillPath), { recursive: true });
|
|
231
155
|
await atomicWrite(SKILL_CONTENT, skillPath);
|
|
232
156
|
}
|
|
157
|
+
function validateChannelShimPrereq(channelPath) {
|
|
158
|
+
return existsSync(channelPath);
|
|
159
|
+
}
|
|
233
160
|
async function runSetup(opts = {}) {
|
|
234
161
|
console.error("\nTandem Setup\n");
|
|
162
|
+
if (opts.withChannelShim && !validateChannelShimPrereq(CHANNEL_DIST)) {
|
|
163
|
+
console.error(
|
|
164
|
+
`Error: --with-channel-shim requires dist/channel/index.js at ${CHANNEL_DIST}
|
|
165
|
+
Run 'npm run build' first, or drop --with-channel-shim to use the plugin monitor.`
|
|
166
|
+
);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
235
169
|
console.error("Detecting Claude installations...");
|
|
236
170
|
const targets = detectTargets({ force: opts.force });
|
|
237
171
|
if (targets.length === 0) {
|
|
@@ -244,7 +178,7 @@ async function runSetup(opts = {}) {
|
|
|
244
178
|
console.error(` Found: ${t.label} (${t.configPath})`);
|
|
245
179
|
}
|
|
246
180
|
console.error("\nWriting MCP configuration...");
|
|
247
|
-
const entries = buildMcpEntries(CHANNEL_DIST);
|
|
181
|
+
const entries = buildMcpEntries(CHANNEL_DIST, { withChannelShim: opts.withChannelShim });
|
|
248
182
|
let failures = 0;
|
|
249
183
|
for (const t of targets) {
|
|
250
184
|
try {
|
|
@@ -279,23 +213,656 @@ Setup partially complete (${failures} target(s) failed). Start Tandem with: tand
|
|
|
279
213
|
);
|
|
280
214
|
}
|
|
281
215
|
if (failures < targets.length) {
|
|
216
|
+
const pluginManifest = join(PACKAGE_ROOT, ".claude-plugin", "plugin.json");
|
|
217
|
+
const devInstructions = existsSync(pluginManifest) ? ` Or for development, load directly from this package:
|
|
218
|
+
|
|
219
|
+
claude --plugin-dir ${PACKAGE_ROOT}
|
|
220
|
+
|
|
221
|
+
` : ` (Development plugin dir not found at ${pluginManifest}; skipping local-plugin instructions.)
|
|
222
|
+
|
|
223
|
+
`;
|
|
282
224
|
console.error(
|
|
283
|
-
"\n\x1B[1mReal-time push notifications (
|
|
225
|
+
"\n\x1B[1mReal-time push notifications (recommended):\x1B[0m\n Install the Tandem plugin for instant events (one-time):\n\n claude plugin marketplace add bloknayrb/tandem\n claude plugin install tandem@tandem-editor\n\n" + devInstructions + " Without the plugin, Claude still works but relies on tandem_checkInbox polling.\n"
|
|
284
226
|
);
|
|
285
227
|
}
|
|
286
228
|
}
|
|
287
|
-
var
|
|
229
|
+
var __dirname2, PACKAGE_ROOT, CHANNEL_DIST, MCP_URL;
|
|
288
230
|
var init_setup = __esm({
|
|
289
231
|
"src/cli/setup.ts"() {
|
|
290
232
|
"use strict";
|
|
291
233
|
init_constants();
|
|
292
234
|
init_skill_content();
|
|
293
|
-
|
|
294
|
-
|
|
235
|
+
__dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
236
|
+
PACKAGE_ROOT = resolve2(__dirname2, "../..");
|
|
237
|
+
CHANNEL_DIST = resolve2(PACKAGE_ROOT, "dist/channel/index.js");
|
|
295
238
|
MCP_URL = `http://localhost:${DEFAULT_MCP_PORT}`;
|
|
296
239
|
}
|
|
297
240
|
});
|
|
298
241
|
|
|
242
|
+
// src/shared/cli-runtime.ts
|
|
243
|
+
function redirectConsoleToStderr() {
|
|
244
|
+
console.log = console.error;
|
|
245
|
+
console.warn = console.error;
|
|
246
|
+
console.info = console.error;
|
|
247
|
+
}
|
|
248
|
+
function resolveTandemUrl(override) {
|
|
249
|
+
const raw = override ?? process.env.TANDEM_URL ?? `http://localhost:${DEFAULT_MCP_PORT}`;
|
|
250
|
+
return raw.replace(/\/$/, "");
|
|
251
|
+
}
|
|
252
|
+
var init_cli_runtime = __esm({
|
|
253
|
+
"src/shared/cli-runtime.ts"() {
|
|
254
|
+
"use strict";
|
|
255
|
+
init_constants();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// src/cli/preflight.ts
|
|
260
|
+
async function ensureTandemServer(opts = {}) {
|
|
261
|
+
const url = resolveTandemUrl(opts.url);
|
|
262
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
263
|
+
const controller = new AbortController();
|
|
264
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch(`${url}/health`, { signal: controller.signal });
|
|
267
|
+
if (!res.ok) {
|
|
268
|
+
fail(url, `health endpoint returned HTTP ${res.status}`);
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
272
|
+
fail(url, msg);
|
|
273
|
+
} finally {
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function fail(url, detail) {
|
|
278
|
+
process.stderr.write(
|
|
279
|
+
`[tandem] Tandem server not reachable at ${url} (${detail}).
|
|
280
|
+
[tandem] Start the Tauri app or run \`tandem start\` on the host, then retry.
|
|
281
|
+
`
|
|
282
|
+
);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
var DEFAULT_TIMEOUT_MS;
|
|
286
|
+
var init_preflight = __esm({
|
|
287
|
+
"src/cli/preflight.ts"() {
|
|
288
|
+
"use strict";
|
|
289
|
+
init_cli_runtime();
|
|
290
|
+
DEFAULT_TIMEOUT_MS = 2e3;
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// src/cli/mcp-stdio.ts
|
|
295
|
+
var mcp_stdio_exports = {};
|
|
296
|
+
__export(mcp_stdio_exports, {
|
|
297
|
+
getRequestId: () => getRequestId,
|
|
298
|
+
runMcpStdio: () => runMcpStdio
|
|
299
|
+
});
|
|
300
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
301
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
302
|
+
async function runMcpStdio() {
|
|
303
|
+
const baseUrl = resolveTandemUrl();
|
|
304
|
+
await ensureTandemServer({ url: baseUrl });
|
|
305
|
+
const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
|
|
306
|
+
const stdio = new StdioServerTransport();
|
|
307
|
+
let shuttingDown = false;
|
|
308
|
+
const shutdown = async (code = 0) => {
|
|
309
|
+
if (!shuttingDown) {
|
|
310
|
+
shuttingDown = true;
|
|
311
|
+
await http.close().catch(() => {
|
|
312
|
+
});
|
|
313
|
+
await stdio.close().catch(() => {
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
process.exit(code);
|
|
317
|
+
};
|
|
318
|
+
stdio.onmessage = (msg) => {
|
|
319
|
+
http.send(msg).catch((err) => {
|
|
320
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
321
|
+
process.stderr.write(`[tandem mcp-stdio] upstream send failed: ${detail}
|
|
322
|
+
`);
|
|
323
|
+
const requestId = getRequestId(msg);
|
|
324
|
+
if (requestId !== void 0) {
|
|
325
|
+
const errorResponse = {
|
|
326
|
+
jsonrpc: "2.0",
|
|
327
|
+
id: requestId,
|
|
328
|
+
error: {
|
|
329
|
+
// -32000 is the implementation-defined server error range per
|
|
330
|
+
// JSON-RPC 2.0 §5.1 — the upstream being unreachable is an
|
|
331
|
+
// application-level condition, not a generic Internal Error.
|
|
332
|
+
code: -32e3,
|
|
333
|
+
message: "Tandem HTTP upstream unreachable",
|
|
334
|
+
data: { detail }
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
stdio.send(errorResponse).catch(() => {
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
};
|
|
342
|
+
http.onmessage = (msg) => {
|
|
343
|
+
stdio.send(msg).catch((err) => {
|
|
344
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
345
|
+
process.stderr.write(`[tandem mcp-stdio] stdio write failed: ${detail}
|
|
346
|
+
`);
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
stdio.onerror = (err) => {
|
|
350
|
+
process.stderr.write(`[tandem mcp-stdio] stdio error: ${err.message}
|
|
351
|
+
`);
|
|
352
|
+
};
|
|
353
|
+
http.onerror = (err) => {
|
|
354
|
+
process.stderr.write(`[tandem mcp-stdio] http error: ${err.message}
|
|
355
|
+
`);
|
|
356
|
+
};
|
|
357
|
+
stdio.onclose = () => {
|
|
358
|
+
void shutdown(0);
|
|
359
|
+
};
|
|
360
|
+
http.onclose = () => {
|
|
361
|
+
void shutdown(0);
|
|
362
|
+
};
|
|
363
|
+
await stdio.start();
|
|
364
|
+
try {
|
|
365
|
+
await http.start();
|
|
366
|
+
} catch (err) {
|
|
367
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
368
|
+
process.stderr.write(`[tandem mcp-stdio] upstream http start failed: ${detail}
|
|
369
|
+
`);
|
|
370
|
+
await shutdown(1);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function getRequestId(msg) {
|
|
374
|
+
const m = msg;
|
|
375
|
+
if (typeof m.method !== "string") return void 0;
|
|
376
|
+
if (typeof m.id === "string" || typeof m.id === "number") return m.id;
|
|
377
|
+
return void 0;
|
|
378
|
+
}
|
|
379
|
+
var init_mcp_stdio = __esm({
|
|
380
|
+
"src/cli/mcp-stdio.ts"() {
|
|
381
|
+
"use strict";
|
|
382
|
+
init_cli_runtime();
|
|
383
|
+
init_preflight();
|
|
384
|
+
redirectConsoleToStderr();
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// src/shared/utils.ts
|
|
389
|
+
var init_utils = __esm({
|
|
390
|
+
"src/shared/utils.ts"() {
|
|
391
|
+
"use strict";
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// src/server/events/types.ts
|
|
396
|
+
function parseTandemEvent(raw) {
|
|
397
|
+
if (typeof raw !== "object" || raw === null || !("id" in raw) || typeof raw.id !== "string" || !("type" in raw) || !VALID_EVENT_TYPES.has(raw.type) || !("timestamp" in raw) || typeof raw.timestamp !== "number" || !("payload" in raw) || typeof raw.payload !== "object") {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
return raw;
|
|
401
|
+
}
|
|
402
|
+
function formatEventContent(event) {
|
|
403
|
+
const doc = event.documentId ? ` [doc: ${event.documentId}]` : "";
|
|
404
|
+
switch (event.type) {
|
|
405
|
+
case "annotation:created": {
|
|
406
|
+
const { annotationType, content, textSnippet, hasSuggestedText, directedAt } = event.payload;
|
|
407
|
+
const snippet = textSnippet ? ` on "${textSnippet}"` : "";
|
|
408
|
+
const label = hasSuggestedText ? "replacement" : directedAt === "claude" ? "question for Claude" : annotationType;
|
|
409
|
+
return `User created ${label}${snippet}: ${content || "(no content)"}${doc}`;
|
|
410
|
+
}
|
|
411
|
+
case "annotation:accepted": {
|
|
412
|
+
const { annotationId, textSnippet } = event.payload;
|
|
413
|
+
return `User accepted annotation ${annotationId}${textSnippet ? ` ("${textSnippet}")` : ""}${doc}`;
|
|
414
|
+
}
|
|
415
|
+
case "annotation:dismissed": {
|
|
416
|
+
const { annotationId, textSnippet } = event.payload;
|
|
417
|
+
return `User dismissed annotation ${annotationId}${textSnippet ? ` ("${textSnippet}")` : ""}${doc}`;
|
|
418
|
+
}
|
|
419
|
+
case "annotation:reply": {
|
|
420
|
+
const { annotationId, replyAuthor, replyText, textSnippet } = event.payload;
|
|
421
|
+
const who = replyAuthor === "claude" ? "Claude" : "User";
|
|
422
|
+
const snippet = textSnippet ? ` (on "${textSnippet}")` : "";
|
|
423
|
+
return `${who} replied to annotation ${annotationId}${snippet}: ${replyText}${doc}`;
|
|
424
|
+
}
|
|
425
|
+
case "chat:message": {
|
|
426
|
+
const { text, replyTo, selection } = event.payload;
|
|
427
|
+
const reply = replyTo ? ` (replying to ${replyTo})` : "";
|
|
428
|
+
const sel = selection && selection.selectedText ? ` [selection: "${selection.selectedText}"${"from" in selection ? ` (${selection.from}-${selection.to})` : ""}]` : "";
|
|
429
|
+
return `User says${reply}: ${text}${sel}${doc}`;
|
|
430
|
+
}
|
|
431
|
+
case "document:opened": {
|
|
432
|
+
const { fileName, format } = event.payload;
|
|
433
|
+
return `User opened document: ${fileName} (${format})${doc}`;
|
|
434
|
+
}
|
|
435
|
+
case "document:closed": {
|
|
436
|
+
const { fileName } = event.payload;
|
|
437
|
+
return `User closed document: ${fileName}${doc}`;
|
|
438
|
+
}
|
|
439
|
+
case "document:switched": {
|
|
440
|
+
const { fileName } = event.payload;
|
|
441
|
+
return `User switched to document: ${fileName}${doc}`;
|
|
442
|
+
}
|
|
443
|
+
default: {
|
|
444
|
+
const _exhaustive = event;
|
|
445
|
+
return `Unknown event${doc}`;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function formatEventMeta(event) {
|
|
450
|
+
const meta = {
|
|
451
|
+
event_type: event.type
|
|
452
|
+
};
|
|
453
|
+
if (event.documentId) meta.document_id = event.documentId;
|
|
454
|
+
switch (event.type) {
|
|
455
|
+
case "annotation:created":
|
|
456
|
+
case "annotation:accepted":
|
|
457
|
+
case "annotation:dismissed":
|
|
458
|
+
meta.annotation_id = event.payload.annotationId;
|
|
459
|
+
break;
|
|
460
|
+
case "annotation:reply":
|
|
461
|
+
meta.annotation_id = event.payload.annotationId;
|
|
462
|
+
meta.reply_id = event.payload.replyId;
|
|
463
|
+
break;
|
|
464
|
+
case "chat:message":
|
|
465
|
+
meta.message_id = event.payload.messageId;
|
|
466
|
+
if (event.payload.selection?.selectedText) meta.has_selection = "true";
|
|
467
|
+
break;
|
|
468
|
+
case "document:opened":
|
|
469
|
+
case "document:closed":
|
|
470
|
+
case "document:switched":
|
|
471
|
+
break;
|
|
472
|
+
default: {
|
|
473
|
+
const _exhaustive = event;
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return meta;
|
|
478
|
+
}
|
|
479
|
+
var VALID_EVENT_TYPES;
|
|
480
|
+
var init_types = __esm({
|
|
481
|
+
"src/server/events/types.ts"() {
|
|
482
|
+
"use strict";
|
|
483
|
+
init_utils();
|
|
484
|
+
VALID_EVENT_TYPES = /* @__PURE__ */ new Set([
|
|
485
|
+
"annotation:created",
|
|
486
|
+
"annotation:accepted",
|
|
487
|
+
"annotation:dismissed",
|
|
488
|
+
"annotation:reply",
|
|
489
|
+
"chat:message",
|
|
490
|
+
"document:opened",
|
|
491
|
+
"document:closed",
|
|
492
|
+
"document:switched"
|
|
493
|
+
]);
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// src/channel/event-bridge.ts
|
|
498
|
+
async function startEventBridge(mcp, tandemUrl) {
|
|
499
|
+
let retries = 0;
|
|
500
|
+
let lastEventId;
|
|
501
|
+
while (retries < CHANNEL_MAX_RETRIES) {
|
|
502
|
+
try {
|
|
503
|
+
await connectAndStream(mcp, tandemUrl, lastEventId, (id) => {
|
|
504
|
+
lastEventId = id;
|
|
505
|
+
retries = 0;
|
|
506
|
+
});
|
|
507
|
+
} catch (err) {
|
|
508
|
+
retries++;
|
|
509
|
+
console.error(
|
|
510
|
+
`[Channel] SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,
|
|
511
|
+
err instanceof Error ? err.message : err
|
|
512
|
+
);
|
|
513
|
+
if (retries >= CHANNEL_MAX_RETRIES) {
|
|
514
|
+
console.error("[Channel] SSE connection exhausted, reporting error and exiting");
|
|
515
|
+
try {
|
|
516
|
+
await fetch(`${tandemUrl}/api/channel-error`, {
|
|
517
|
+
method: "POST",
|
|
518
|
+
headers: { "Content-Type": "application/json" },
|
|
519
|
+
body: JSON.stringify({
|
|
520
|
+
error: "CHANNEL_CONNECT_FAILED",
|
|
521
|
+
message: `Channel shim lost connection after ${CHANNEL_MAX_RETRIES} retries.`
|
|
522
|
+
})
|
|
523
|
+
});
|
|
524
|
+
} catch (reportErr) {
|
|
525
|
+
console.error(
|
|
526
|
+
"[Channel] Could not report failure to server:",
|
|
527
|
+
reportErr instanceof Error ? reportErr.message : reportErr
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
await new Promise((r) => setTimeout(r, CHANNEL_RETRY_DELAY_MS));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
537
|
+
const headers = { Accept: "text/event-stream" };
|
|
538
|
+
if (lastEventId) headers["Last-Event-ID"] = lastEventId;
|
|
539
|
+
const res = await fetch(`${tandemUrl}/api/events`, { headers });
|
|
540
|
+
if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
|
|
541
|
+
if (!res.body) throw new Error("SSE endpoint returned no body");
|
|
542
|
+
const reader = res.body.getReader();
|
|
543
|
+
const decoder = new TextDecoder();
|
|
544
|
+
let buffer = "";
|
|
545
|
+
let awarenessTimer = null;
|
|
546
|
+
let clearAwarenessTimer = null;
|
|
547
|
+
let pendingAwareness = null;
|
|
548
|
+
const AWARENESS_CLEAR_MS = 3e3;
|
|
549
|
+
function clearAwareness(documentId) {
|
|
550
|
+
fetch(`${tandemUrl}/api/channel-awareness`, {
|
|
551
|
+
method: "POST",
|
|
552
|
+
headers: { "Content-Type": "application/json" },
|
|
553
|
+
body: JSON.stringify({
|
|
554
|
+
documentId: documentId ?? null,
|
|
555
|
+
status: "idle",
|
|
556
|
+
active: false
|
|
557
|
+
})
|
|
558
|
+
}).catch(() => {
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
function flushAwareness() {
|
|
562
|
+
if (!pendingAwareness) return;
|
|
563
|
+
const event = pendingAwareness;
|
|
564
|
+
pendingAwareness = null;
|
|
565
|
+
fetch(`${tandemUrl}/api/channel-awareness`, {
|
|
566
|
+
method: "POST",
|
|
567
|
+
headers: { "Content-Type": "application/json" },
|
|
568
|
+
body: JSON.stringify({
|
|
569
|
+
documentId: event.documentId,
|
|
570
|
+
status: `processing: ${event.type}`,
|
|
571
|
+
active: true
|
|
572
|
+
})
|
|
573
|
+
}).catch((err) => {
|
|
574
|
+
console.error("[Channel] Awareness update failed:", err instanceof Error ? err.message : err);
|
|
575
|
+
});
|
|
576
|
+
if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
|
|
577
|
+
clearAwarenessTimer = setTimeout(() => clearAwareness(event.documentId), AWARENESS_CLEAR_MS);
|
|
578
|
+
}
|
|
579
|
+
function scheduleAwareness(event) {
|
|
580
|
+
pendingAwareness = event;
|
|
581
|
+
if (awarenessTimer) clearTimeout(awarenessTimer);
|
|
582
|
+
awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
|
|
583
|
+
}
|
|
584
|
+
while (true) {
|
|
585
|
+
const { done, value } = await reader.read();
|
|
586
|
+
if (done) throw new Error("SSE stream ended");
|
|
587
|
+
buffer += decoder.decode(value, { stream: true });
|
|
588
|
+
let boundary;
|
|
589
|
+
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
|
590
|
+
const frame = buffer.slice(0, boundary);
|
|
591
|
+
buffer = buffer.slice(boundary + 2);
|
|
592
|
+
if (frame.startsWith(":")) continue;
|
|
593
|
+
let eventId;
|
|
594
|
+
let data;
|
|
595
|
+
for (const line of frame.split("\n")) {
|
|
596
|
+
if (line.startsWith("id: ")) eventId = line.slice(4);
|
|
597
|
+
else if (line.startsWith("data: ")) data = line.slice(6);
|
|
598
|
+
}
|
|
599
|
+
if (!data) continue;
|
|
600
|
+
let event;
|
|
601
|
+
try {
|
|
602
|
+
event = parseTandemEvent(JSON.parse(data));
|
|
603
|
+
} catch {
|
|
604
|
+
console.error("[Channel] Malformed SSE event data (skipping):", data.slice(0, 200));
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (!event) {
|
|
608
|
+
console.error("[Channel] Received invalid SSE event, skipping");
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
if (event.type !== "chat:message") {
|
|
612
|
+
const mode = await getCachedMode(tandemUrl);
|
|
613
|
+
if (mode === "solo") {
|
|
614
|
+
console.error(`[Channel] Solo mode: suppressed ${event.type} event`);
|
|
615
|
+
if (eventId) onEventId(eventId);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (eventId) onEventId(eventId);
|
|
620
|
+
try {
|
|
621
|
+
await mcp.notification({
|
|
622
|
+
method: "notifications/claude/channel",
|
|
623
|
+
params: {
|
|
624
|
+
content: formatEventContent(event),
|
|
625
|
+
meta: formatEventMeta(event)
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
} catch (err) {
|
|
629
|
+
console.error("[Channel] MCP notification failed (transport broken?):", err);
|
|
630
|
+
throw err;
|
|
631
|
+
}
|
|
632
|
+
scheduleAwareness(event);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async function getCachedMode(tandemUrl) {
|
|
637
|
+
const now = Date.now();
|
|
638
|
+
if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
|
|
639
|
+
try {
|
|
640
|
+
const res = await fetch(`${tandemUrl}/api/mode`);
|
|
641
|
+
if (res.ok) {
|
|
642
|
+
const { mode } = await res.json();
|
|
643
|
+
cachedMode = mode;
|
|
644
|
+
} else {
|
|
645
|
+
console.error(`[Channel] Mode check returned ${res.status}, using cached: "${cachedMode}"`);
|
|
646
|
+
}
|
|
647
|
+
cachedModeAt = now;
|
|
648
|
+
} catch (err) {
|
|
649
|
+
console.error(
|
|
650
|
+
"[Channel] Mode check failed, delivering event (fail-open):",
|
|
651
|
+
err instanceof Error ? err.message : err
|
|
652
|
+
);
|
|
653
|
+
cachedModeAt = now;
|
|
654
|
+
}
|
|
655
|
+
return cachedMode;
|
|
656
|
+
}
|
|
657
|
+
var AWARENESS_DEBOUNCE_MS, MODE_CACHE_TTL_MS, cachedMode, cachedModeAt;
|
|
658
|
+
var init_event_bridge = __esm({
|
|
659
|
+
"src/channel/event-bridge.ts"() {
|
|
660
|
+
"use strict";
|
|
661
|
+
init_types();
|
|
662
|
+
init_constants();
|
|
663
|
+
AWARENESS_DEBOUNCE_MS = 500;
|
|
664
|
+
MODE_CACHE_TTL_MS = 2e3;
|
|
665
|
+
cachedMode = "tandem";
|
|
666
|
+
cachedModeAt = 0;
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// src/channel/run.ts
|
|
671
|
+
import { createConnection } from "net";
|
|
672
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
673
|
+
import { StdioServerTransport as StdioServerTransport2 } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
674
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
675
|
+
import { z } from "zod";
|
|
676
|
+
async function runChannel(opts = {}) {
|
|
677
|
+
redirectConsoleToStderr();
|
|
678
|
+
const tandemUrl = resolveTandemUrl();
|
|
679
|
+
const mcp = new Server(
|
|
680
|
+
{ name: "tandem-channel", version: "0.1.0" },
|
|
681
|
+
{
|
|
682
|
+
capabilities: {
|
|
683
|
+
experimental: {
|
|
684
|
+
"claude/channel": {},
|
|
685
|
+
"claude/channel/permission": {}
|
|
686
|
+
},
|
|
687
|
+
tools: {}
|
|
688
|
+
},
|
|
689
|
+
instructions: [
|
|
690
|
+
'Events from Tandem arrive as <channel source="tandem-channel" event_type="..." document_id="...">.',
|
|
691
|
+
"These are real-time push notifications of user actions in the collaborative document editor.",
|
|
692
|
+
"Event types: annotation:created, annotation:accepted, annotation:dismissed, annotation:reply,",
|
|
693
|
+
"chat:message, document:opened, document:closed, document:switched.",
|
|
694
|
+
"Chat messages may include a 'selection' field with buffered selection context.",
|
|
695
|
+
"Use your tandem MCP tools (tandem_getTextContent, tandem_comment, tandem_highlight, etc.) to act on them.",
|
|
696
|
+
"Reply to chat messages using tandem_reply. Pass document_id from the tag attributes.",
|
|
697
|
+
"Do not reply to non-chat events \u2014 just act on them using tools.",
|
|
698
|
+
"If you haven't received channel notifications recently, call tandem_checkInbox as a fallback."
|
|
699
|
+
].join(" ")
|
|
700
|
+
}
|
|
701
|
+
);
|
|
702
|
+
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
703
|
+
tools: [
|
|
704
|
+
{
|
|
705
|
+
name: "tandem_reply",
|
|
706
|
+
description: "Reply to a chat message in Tandem",
|
|
707
|
+
inputSchema: {
|
|
708
|
+
type: "object",
|
|
709
|
+
properties: {
|
|
710
|
+
text: { type: "string", description: "The reply message" },
|
|
711
|
+
documentId: {
|
|
712
|
+
type: "string",
|
|
713
|
+
description: "Document ID from the channel event (optional)"
|
|
714
|
+
},
|
|
715
|
+
replyTo: {
|
|
716
|
+
type: "string",
|
|
717
|
+
description: "Message ID being replied to (optional)"
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
required: ["text"]
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
]
|
|
724
|
+
}));
|
|
725
|
+
mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
726
|
+
if (req.params.name === "tandem_reply") {
|
|
727
|
+
const args2 = req.params.arguments;
|
|
728
|
+
try {
|
|
729
|
+
const res = await fetch(`${tandemUrl}/api/channel-reply`, {
|
|
730
|
+
method: "POST",
|
|
731
|
+
headers: { "Content-Type": "application/json" },
|
|
732
|
+
body: JSON.stringify(args2)
|
|
733
|
+
});
|
|
734
|
+
let data;
|
|
735
|
+
try {
|
|
736
|
+
data = await res.json();
|
|
737
|
+
} catch {
|
|
738
|
+
data = { message: "Non-JSON response" };
|
|
739
|
+
}
|
|
740
|
+
if (!res.ok) {
|
|
741
|
+
return {
|
|
742
|
+
content: [
|
|
743
|
+
{
|
|
744
|
+
type: "text",
|
|
745
|
+
text: `Reply failed (${res.status}): ${JSON.stringify(data)}`
|
|
746
|
+
}
|
|
747
|
+
],
|
|
748
|
+
isError: true
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
752
|
+
} catch (err) {
|
|
753
|
+
return {
|
|
754
|
+
content: [
|
|
755
|
+
{
|
|
756
|
+
type: "text",
|
|
757
|
+
text: `Failed to send reply: ${err instanceof Error ? err.message : String(err)}`
|
|
758
|
+
}
|
|
759
|
+
],
|
|
760
|
+
isError: true
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
throw new Error(`Unknown tool: ${req.params.name}`);
|
|
765
|
+
});
|
|
766
|
+
const PermissionRequestSchema = z.object({
|
|
767
|
+
method: z.literal("notifications/claude/channel/permission_request"),
|
|
768
|
+
params: z.object({
|
|
769
|
+
request_id: z.string(),
|
|
770
|
+
tool_name: z.string(),
|
|
771
|
+
description: z.string(),
|
|
772
|
+
input_preview: z.string()
|
|
773
|
+
})
|
|
774
|
+
});
|
|
775
|
+
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
|
|
776
|
+
try {
|
|
777
|
+
const res = await fetch(`${tandemUrl}/api/channel-permission`, {
|
|
778
|
+
method: "POST",
|
|
779
|
+
headers: { "Content-Type": "application/json" },
|
|
780
|
+
body: JSON.stringify({
|
|
781
|
+
requestId: params.request_id,
|
|
782
|
+
toolName: params.tool_name,
|
|
783
|
+
description: params.description,
|
|
784
|
+
inputPreview: params.input_preview
|
|
785
|
+
})
|
|
786
|
+
});
|
|
787
|
+
if (!res.ok) {
|
|
788
|
+
console.error(
|
|
789
|
+
`[Channel] Permission relay got HTTP ${res.status} \u2014 browser may not see prompt`
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
} catch (err) {
|
|
793
|
+
console.error("[Channel] Failed to forward permission request:", err);
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
console.error(`[Channel] Tandem channel shim starting (server: ${tandemUrl})`);
|
|
797
|
+
if (!opts.skipReachabilityLog) {
|
|
798
|
+
const reachable = await checkServerReachable(tandemUrl);
|
|
799
|
+
if (!reachable) {
|
|
800
|
+
console.error(`[Channel] Cannot reach Tandem server at ${tandemUrl}`);
|
|
801
|
+
console.error("[Channel] Start it with: tandem start");
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const transport = new StdioServerTransport2();
|
|
805
|
+
await mcp.connect(transport);
|
|
806
|
+
console.error("[Channel] Connected to Claude Code via stdio");
|
|
807
|
+
startEventBridge(mcp, tandemUrl).catch((err) => {
|
|
808
|
+
console.error("[Channel] Event bridge failed unexpectedly:", err);
|
|
809
|
+
process.exit(1);
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
async function checkServerReachable(url, timeoutMs = 2e3) {
|
|
813
|
+
let parsed;
|
|
814
|
+
try {
|
|
815
|
+
parsed = new URL(url);
|
|
816
|
+
} catch {
|
|
817
|
+
console.error(
|
|
818
|
+
`[Channel] Invalid TANDEM_URL: "${url}" \u2014 expected format: http://localhost:3479`
|
|
819
|
+
);
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
const port = parseInt(parsed.port || String(DEFAULT_MCP_PORT), 10);
|
|
823
|
+
return new Promise((resolve4) => {
|
|
824
|
+
const socket = createConnection({ port, host: parsed.hostname }, () => {
|
|
825
|
+
socket.destroy();
|
|
826
|
+
resolve4(true);
|
|
827
|
+
});
|
|
828
|
+
socket.setTimeout(timeoutMs);
|
|
829
|
+
socket.on("timeout", () => {
|
|
830
|
+
socket.destroy();
|
|
831
|
+
resolve4(false);
|
|
832
|
+
});
|
|
833
|
+
socket.on("error", (err) => {
|
|
834
|
+
console.error(`[Channel] Server probe failed: ${err.message}`);
|
|
835
|
+
socket.destroy();
|
|
836
|
+
resolve4(false);
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
var init_run = __esm({
|
|
841
|
+
"src/channel/run.ts"() {
|
|
842
|
+
"use strict";
|
|
843
|
+
init_cli_runtime();
|
|
844
|
+
init_constants();
|
|
845
|
+
init_event_bridge();
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// src/cli/channel.ts
|
|
850
|
+
var channel_exports = {};
|
|
851
|
+
__export(channel_exports, {
|
|
852
|
+
runChannelCli: () => runChannelCli
|
|
853
|
+
});
|
|
854
|
+
async function runChannelCli() {
|
|
855
|
+
await ensureTandemServer();
|
|
856
|
+
await runChannel({ skipReachabilityLog: true });
|
|
857
|
+
}
|
|
858
|
+
var init_channel = __esm({
|
|
859
|
+
"src/cli/channel.ts"() {
|
|
860
|
+
"use strict";
|
|
861
|
+
init_run();
|
|
862
|
+
init_preflight();
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
299
866
|
// src/cli/start.ts
|
|
300
867
|
var start_exports = {};
|
|
301
868
|
__export(start_exports, {
|
|
@@ -303,8 +870,8 @@ __export(start_exports, {
|
|
|
303
870
|
});
|
|
304
871
|
import { spawn } from "child_process";
|
|
305
872
|
import { existsSync as existsSync2 } from "fs";
|
|
306
|
-
import { dirname as
|
|
307
|
-
import { fileURLToPath as
|
|
873
|
+
import { dirname as dirname3, resolve as resolve3 } from "path";
|
|
874
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
308
875
|
function runStart() {
|
|
309
876
|
if (!existsSync2(SERVER_DIST)) {
|
|
310
877
|
console.error(`[Tandem] Server not found at ${SERVER_DIST}`);
|
|
@@ -327,27 +894,36 @@ function runStart() {
|
|
|
327
894
|
process.once(sig, () => proc.kill());
|
|
328
895
|
}
|
|
329
896
|
}
|
|
330
|
-
var
|
|
897
|
+
var __dirname3, SERVER_DIST;
|
|
331
898
|
var init_start = __esm({
|
|
332
899
|
"src/cli/start.ts"() {
|
|
333
900
|
"use strict";
|
|
334
|
-
|
|
335
|
-
SERVER_DIST =
|
|
901
|
+
__dirname3 = dirname3(fileURLToPath3(import.meta.url));
|
|
902
|
+
SERVER_DIST = resolve3(__dirname3, "../server/index.js");
|
|
336
903
|
}
|
|
337
904
|
});
|
|
338
905
|
|
|
339
906
|
// src/cli/index.ts
|
|
340
907
|
import updateNotifier from "update-notifier";
|
|
341
|
-
var version = true ? "0.
|
|
342
|
-
updateNotifier({ pkg: { name: "tandem-editor", version } }).notify();
|
|
908
|
+
var version = true ? "0.6.0" : "0.0.0-dev";
|
|
343
909
|
var args = process.argv.slice(2);
|
|
910
|
+
var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
|
|
911
|
+
if (!isStdioMode) {
|
|
912
|
+
updateNotifier({ pkg: { name: "tandem-editor", version } }).notify();
|
|
913
|
+
}
|
|
344
914
|
if (args.includes("--help") || args.includes("-h")) {
|
|
345
915
|
console.log(`tandem v${version}
|
|
346
916
|
|
|
347
917
|
Usage:
|
|
348
|
-
tandem
|
|
349
|
-
tandem setup
|
|
350
|
-
tandem setup --force
|
|
918
|
+
tandem Start Tandem server and open the browser
|
|
919
|
+
tandem setup Register MCP tools with Claude Code / Claude Desktop
|
|
920
|
+
tandem setup --force Register to default paths regardless of detection
|
|
921
|
+
tandem setup --with-channel-shim Also register the stdio channel shim (legacy opt-in)
|
|
922
|
+
tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP
|
|
923
|
+
(used by the plugin's Cowork bridge; requires
|
|
924
|
+
tandem server running on the host)
|
|
925
|
+
tandem channel Run the Tandem channel shim (stdio MCP)
|
|
926
|
+
(used by the plugin's tandem-channel entry)
|
|
351
927
|
tandem --version
|
|
352
928
|
tandem --help
|
|
353
929
|
`);
|
|
@@ -360,7 +936,16 @@ if (args.includes("--version") || args.includes("-v")) {
|
|
|
360
936
|
try {
|
|
361
937
|
if (args[0] === "setup") {
|
|
362
938
|
const { runSetup: runSetup2 } = await Promise.resolve().then(() => (init_setup(), setup_exports));
|
|
363
|
-
await runSetup2({
|
|
939
|
+
await runSetup2({
|
|
940
|
+
force: args.includes("--force"),
|
|
941
|
+
withChannelShim: args.includes("--with-channel-shim")
|
|
942
|
+
});
|
|
943
|
+
} else if (args[0] === "mcp-stdio") {
|
|
944
|
+
const { runMcpStdio: runMcpStdio2 } = await Promise.resolve().then(() => (init_mcp_stdio(), mcp_stdio_exports));
|
|
945
|
+
await runMcpStdio2();
|
|
946
|
+
} else if (args[0] === "channel") {
|
|
947
|
+
const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
|
|
948
|
+
await runChannelCli2();
|
|
364
949
|
} else if (!args[0] || args[0] === "start") {
|
|
365
950
|
const { runStart: runStart2 } = await Promise.resolve().then(() => (init_start(), start_exports));
|
|
366
951
|
runStart2();
|