tandem-editor 0.13.0 → 0.13.6
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 +108 -0
- package/README.md +9 -7
- package/dist/channel/index.js +1 -1
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +538 -20
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/CoworkSettings-C-1BnhBH.css +1 -0
- package/dist/client/assets/CoworkSettings-L5Hw-XtT.js +3 -0
- package/dist/client/assets/index-C4uZDO35.css +1 -0
- package/dist/client/assets/index-DDqbVvCU.js +240 -0
- package/dist/client/index.html +182 -7
- package/dist/monitor/index.js +1 -1
- package/dist/monitor/index.js.map +1 -1
- package/dist/server/index.js +1878 -845
- package/dist/server/index.js.map +1 -1
- package/package.json +10 -4
- package/sample/welcome.md +32 -1
- package/skills/tandem/SKILL.md +11 -1
- package/dist/client/assets/CoworkSettings-BOYbyKul.js +0 -3
- package/dist/client/assets/index-D8uS4cj7.css +0 -1
- package/dist/client/assets/index-g-KwmRn9.js +0 -271
package/dist/cli/index.js
CHANGED
|
@@ -370,10 +370,11 @@ var init_skill_content = __esm({
|
|
|
370
370
|
});
|
|
371
371
|
|
|
372
372
|
// src/shared/constants.ts
|
|
373
|
-
var DEFAULT_MCP_PORT, TANDEM_REPO_URL, TANDEM_ISSUES_NEW_URL, MAX_FILE_SIZE, SESSION_MAX_AGE, TANDEM_MODE_DEFAULT, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS, CHANNEL_CONNECT_FETCH_TIMEOUT_MS, CHANNEL_SSE_INACTIVITY_TIMEOUT_MS, CHANNEL_MODE_FETCH_TIMEOUT_MS, CHANNEL_AWARENESS_FETCH_TIMEOUT_MS, CHANNEL_ERROR_REPORT_TIMEOUT_MS, CHANNEL_REPLY_FETCH_TIMEOUT_MS, CHANNEL_PERMISSION_FETCH_TIMEOUT_MS, CHANNEL_MAX_SSE_BUFFER_BYTES, TOKEN_FILE_NAME;
|
|
373
|
+
var DEFAULT_WS_PORT, DEFAULT_MCP_PORT, TANDEM_REPO_URL, TANDEM_ISSUES_NEW_URL, MAX_FILE_SIZE, SESSION_MAX_AGE, TANDEM_MODE_DEFAULT, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS, CHANNEL_CONNECT_FETCH_TIMEOUT_MS, CHANNEL_SSE_INACTIVITY_TIMEOUT_MS, CHANNEL_MODE_FETCH_TIMEOUT_MS, CHANNEL_AWARENESS_FETCH_TIMEOUT_MS, CHANNEL_ERROR_REPORT_TIMEOUT_MS, CHANNEL_REPLY_FETCH_TIMEOUT_MS, CHANNEL_PERMISSION_FETCH_TIMEOUT_MS, CHANNEL_MAX_SSE_BUFFER_BYTES, TOKEN_FILE_NAME;
|
|
374
374
|
var init_constants = __esm({
|
|
375
375
|
"src/shared/constants.ts"() {
|
|
376
376
|
"use strict";
|
|
377
|
+
DEFAULT_WS_PORT = 3478;
|
|
377
378
|
DEFAULT_MCP_PORT = 3479;
|
|
378
379
|
TANDEM_REPO_URL = "https://github.com/bloknayrb/tandem";
|
|
379
380
|
TANDEM_ISSUES_NEW_URL = `${TANDEM_REPO_URL}/issues/new`;
|
|
@@ -886,6 +887,7 @@ async function applyConfig(configPath, ops) {
|
|
|
886
887
|
...ops.create
|
|
887
888
|
};
|
|
888
889
|
for (const key of ops.remove) {
|
|
890
|
+
if (key in ops.create) continue;
|
|
889
891
|
if (merged[key]) {
|
|
890
892
|
console.error(` Note: removed mcpServers.${key} from ${configPath}`);
|
|
891
893
|
}
|
|
@@ -929,21 +931,24 @@ function readSkillVersion(skillContent) {
|
|
|
929
931
|
function validateChannelShimPrereq(channelPath) {
|
|
930
932
|
return existsSync(channelPath);
|
|
931
933
|
}
|
|
934
|
+
function shouldRegisterChannelShim(targetKind, channelPath, override) {
|
|
935
|
+
if (targetKind === "claude-desktop") return false;
|
|
936
|
+
if (override !== void 0) return override;
|
|
937
|
+
return validateChannelShimPrereq(channelPath);
|
|
938
|
+
}
|
|
932
939
|
async function applyConfigWithToken(token, opts = {}) {
|
|
933
940
|
const targets = detectTargets({ force: opts.force });
|
|
934
941
|
let updated = 0;
|
|
935
942
|
const errors = [];
|
|
936
943
|
for (const t of targets) {
|
|
944
|
+
const withChannelShim = shouldRegisterChannelShim(t.kind, CHANNEL_DIST, opts.withChannelShim);
|
|
937
945
|
const entries = buildMcpEntries(CHANNEL_DIST, {
|
|
938
|
-
withChannelShim
|
|
946
|
+
withChannelShim,
|
|
939
947
|
token: token ?? void 0,
|
|
940
948
|
targetKind: t.kind
|
|
941
949
|
});
|
|
942
950
|
try {
|
|
943
|
-
await applyConfig(
|
|
944
|
-
t.configPath,
|
|
945
|
-
applyOpsForCli(entries, { withChannelShim: !!opts.withChannelShim })
|
|
946
|
-
);
|
|
951
|
+
await applyConfig(t.configPath, applyOpsForCli(entries, { withChannelShim }));
|
|
947
952
|
updated++;
|
|
948
953
|
} catch (err) {
|
|
949
954
|
errors.push(`${t.label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -1021,15 +1026,13 @@ Run 'npm run build' first, or drop --with-channel-shim to use the plugin monitor
|
|
|
1021
1026
|
console.error("\nWriting MCP configuration...");
|
|
1022
1027
|
let failures = 0;
|
|
1023
1028
|
for (const t of targets) {
|
|
1029
|
+
const withChannelShim = shouldRegisterChannelShim(t.kind, CHANNEL_DIST, opts.withChannelShim);
|
|
1024
1030
|
const entries = buildMcpEntries(CHANNEL_DIST, {
|
|
1025
|
-
withChannelShim
|
|
1031
|
+
withChannelShim,
|
|
1026
1032
|
targetKind: t.kind
|
|
1027
1033
|
});
|
|
1028
1034
|
try {
|
|
1029
|
-
await applyConfig(
|
|
1030
|
-
t.configPath,
|
|
1031
|
-
applyOpsForCli(entries, { withChannelShim: !!opts.withChannelShim })
|
|
1032
|
-
);
|
|
1035
|
+
await applyConfig(t.configPath, applyOpsForCli(entries, { withChannelShim }));
|
|
1033
1036
|
console.error(` \x1B[32m\u2713\x1B[0m ${t.label}`);
|
|
1034
1037
|
} catch (err) {
|
|
1035
1038
|
failures++;
|
|
@@ -1060,16 +1063,15 @@ Setup partially complete (${failures} target(s) failed). Start Tandem with: tand
|
|
|
1060
1063
|
);
|
|
1061
1064
|
}
|
|
1062
1065
|
if (failures < targets.length) {
|
|
1066
|
+
const channelRegistered = validateChannelShimPrereq(CHANNEL_DIST);
|
|
1063
1067
|
const pluginManifest = join4(PACKAGE_ROOT, ".claude-plugin", "plugin.json");
|
|
1064
|
-
const devInstructions = existsSync2(pluginManifest) ? `
|
|
1068
|
+
const devInstructions = existsSync2(pluginManifest) ? ` For development, you can also load the package directly:
|
|
1065
1069
|
|
|
1066
1070
|
claude --plugin-dir ${PACKAGE_ROOT}
|
|
1067
1071
|
|
|
1068
|
-
` :
|
|
1069
|
-
|
|
1070
|
-
`;
|
|
1072
|
+
` : "";
|
|
1071
1073
|
console.error(
|
|
1072
|
-
"\n\x1B[1mReal-time push notifications (
|
|
1074
|
+
"\n\x1B[1mReal-time push notifications:\x1B[0m\n" + (channelRegistered ? " \x1B[32mEnabled\x1B[0m \u2014 the channel shim is registered; Claude Code receives events in real time.\n Relaunch any Claude Code session you started manually so it picks up the new server.\n\n" : " \x1B[33mUnavailable\x1B[0m \u2014 dist/channel/index.js not found; Claude Code will poll via tandem_checkInbox.\n Run 'npm run build' and re-run setup to enable push.\n\n") + " A Tandem plugin is also published (skill + MCP; the real-time monitor it carries is\n forward-looking, pending Claude Code support):\n\n claude plugin marketplace add bloknayrb/tandem\n claude plugin install tandem@tandem-editor\n\n" + devInstructions
|
|
1073
1075
|
);
|
|
1074
1076
|
}
|
|
1075
1077
|
}
|
|
@@ -1644,7 +1646,7 @@ var init_types3 = __esm({
|
|
|
1644
1646
|
SeveritySchema = z.enum(["info", "warning", "error", "success"]);
|
|
1645
1647
|
TandemModeSchema = z.enum(["solo", "tandem"]);
|
|
1646
1648
|
AuthorSchema = z.enum(["user", "claude", "import"]);
|
|
1647
|
-
ReplyAuthorSchema = z.enum(["user", "claude"]);
|
|
1649
|
+
ReplyAuthorSchema = z.enum(["user", "claude", "import"]);
|
|
1648
1650
|
AnnotationActionSchema = z.enum(["accept", "dismiss"]);
|
|
1649
1651
|
ExportFormatSchema = z.enum(["markdown", "json"]);
|
|
1650
1652
|
DocumentFormatSchema = z.enum(["md", "txt", "html", "docx"]);
|
|
@@ -2228,6 +2230,515 @@ var init_channel = __esm({
|
|
|
2228
2230
|
}
|
|
2229
2231
|
});
|
|
2230
2232
|
|
|
2233
|
+
// src/cli/doctor.ts
|
|
2234
|
+
var doctor_exports = {};
|
|
2235
|
+
__export(doctor_exports, {
|
|
2236
|
+
runDoctor: () => runDoctor,
|
|
2237
|
+
runDoctorCli: () => runDoctorCli
|
|
2238
|
+
});
|
|
2239
|
+
import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
|
|
2240
|
+
import { request } from "http";
|
|
2241
|
+
import { createConnection as createConnection2 } from "net";
|
|
2242
|
+
import { homedir as homedir2, platform } from "os";
|
|
2243
|
+
import { join as join5 } from "path";
|
|
2244
|
+
function checkNodeVersion(r) {
|
|
2245
|
+
const version2 = process.version;
|
|
2246
|
+
const major = Number.parseInt(version2.slice(1), 10);
|
|
2247
|
+
if (major >= 22) {
|
|
2248
|
+
r.pass(`Node.js ${version2} (>= 22 required)`);
|
|
2249
|
+
} else {
|
|
2250
|
+
r.fail(
|
|
2251
|
+
`Node.js ${version2} \u2014 version 22+ required`,
|
|
2252
|
+
"Install Node.js 22+ from https://nodejs.org"
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
function checkNodeModules(r) {
|
|
2257
|
+
if (existsSync3(join5(process.cwd(), "node_modules"))) {
|
|
2258
|
+
r.pass("node_modules/ exists");
|
|
2259
|
+
} else {
|
|
2260
|
+
r.fail("node_modules/ not found", "npm install");
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
function checkMcpJson(r) {
|
|
2264
|
+
const mcpPath = join5(process.cwd(), ".mcp.json");
|
|
2265
|
+
if (!existsSync3(mcpPath)) {
|
|
2266
|
+
r.fail(".mcp.json not found", "Restore it from git: git checkout .mcp.json");
|
|
2267
|
+
return;
|
|
2268
|
+
}
|
|
2269
|
+
let raw;
|
|
2270
|
+
try {
|
|
2271
|
+
raw = readFileSync3(mcpPath, "utf-8");
|
|
2272
|
+
} catch (err) {
|
|
2273
|
+
r.fail(`.mcp.json could not be read: ${errMsg(err)}`);
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
let config;
|
|
2277
|
+
try {
|
|
2278
|
+
config = JSON.parse(raw);
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
r.fail(`.mcp.json is not valid JSON: ${errMsg(err)}`);
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
const servers = config.mcpServers;
|
|
2284
|
+
if (!servers) {
|
|
2285
|
+
r.fail('.mcp.json missing "mcpServers" key');
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
const tandem = servers.tandem;
|
|
2289
|
+
if (!tandem) {
|
|
2290
|
+
r.fail('.mcp.json missing "tandem" server entry');
|
|
2291
|
+
} else if (tandem.type !== "http" || !tandem.url?.includes("/mcp")) {
|
|
2292
|
+
r.warn(`.mcp.json tandem: unexpected config \u2014 type=${tandem.type}, url=${tandem.url}`);
|
|
2293
|
+
} else {
|
|
2294
|
+
r.pass(`.mcp.json tandem \u2192 ${tandem.url}`);
|
|
2295
|
+
}
|
|
2296
|
+
const channel = servers["tandem-channel"];
|
|
2297
|
+
if (!channel) {
|
|
2298
|
+
r.warn(
|
|
2299
|
+
".mcp.json missing tandem-channel \u2014 Claude will use polling instead of push notifications"
|
|
2300
|
+
);
|
|
2301
|
+
} else {
|
|
2302
|
+
const cmd = channel.command;
|
|
2303
|
+
const args2 = (channel.args || []).join(" ");
|
|
2304
|
+
if (cmd === "cmd" && args2.includes("/c")) {
|
|
2305
|
+
r.warn(
|
|
2306
|
+
`.mcp.json tandem-channel uses Windows-only "cmd /c" \u2014 won't work on macOS/Linux`,
|
|
2307
|
+
'Change to: "command": "npx", "args": ["tsx", "src/channel/index.ts"]'
|
|
2308
|
+
);
|
|
2309
|
+
} else {
|
|
2310
|
+
r.pass(`.mcp.json tandem-channel \u2192 ${cmd} ${args2}`);
|
|
2311
|
+
}
|
|
2312
|
+
if (!channel.env?.TANDEM_URL) {
|
|
2313
|
+
r.warn(
|
|
2314
|
+
"tandem-channel missing TANDEM_URL env var",
|
|
2315
|
+
'Add "env": {"TANDEM_URL": "http://127.0.0.1:3479"}'
|
|
2316
|
+
);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
function checkUserMcpConfig(r) {
|
|
2321
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
2322
|
+
const claudeCodePath = join5(home, ".claude.json");
|
|
2323
|
+
if (!existsSync3(claudeCodePath)) {
|
|
2324
|
+
r.warn(
|
|
2325
|
+
"~/.claude.json not found",
|
|
2326
|
+
"Run: tandem setup (or ignore if using project-local .mcp.json)"
|
|
2327
|
+
);
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
let config;
|
|
2331
|
+
try {
|
|
2332
|
+
config = JSON.parse(readFileSync3(claudeCodePath, "utf-8"));
|
|
2333
|
+
} catch (err) {
|
|
2334
|
+
r.warn(`~/.claude.json is malformed JSON: ${errMsg(err)}`, "Run: tandem setup to rewrite it");
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
const servers = config?.mcpServers ?? {};
|
|
2338
|
+
if (!servers.tandem) {
|
|
2339
|
+
r.warn("tandem not registered in ~/.claude.json", "Run: tandem setup");
|
|
2340
|
+
} else {
|
|
2341
|
+
r.pass("tandem registered in ~/.claude.json");
|
|
2342
|
+
}
|
|
2343
|
+
if (!servers["tandem-channel"]) {
|
|
2344
|
+
r.warn(
|
|
2345
|
+
"tandem-channel not registered in ~/.claude.json \u2014 Claude Code will poll instead of receiving real-time push",
|
|
2346
|
+
"Run: tandem setup"
|
|
2347
|
+
);
|
|
2348
|
+
} else {
|
|
2349
|
+
r.pass("tandem-channel registered in ~/.claude.json");
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
function probePort(port, timeoutMs = 2e3) {
|
|
2353
|
+
return new Promise((resolve4) => {
|
|
2354
|
+
const socket = createConnection2({ port, host: "127.0.0.1" }, () => {
|
|
2355
|
+
socket.destroy();
|
|
2356
|
+
resolve4(true);
|
|
2357
|
+
});
|
|
2358
|
+
socket.setTimeout(timeoutMs);
|
|
2359
|
+
socket.on("timeout", () => {
|
|
2360
|
+
socket.destroy();
|
|
2361
|
+
resolve4(false);
|
|
2362
|
+
});
|
|
2363
|
+
socket.on("error", () => {
|
|
2364
|
+
socket.destroy();
|
|
2365
|
+
resolve4(false);
|
|
2366
|
+
});
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
async function checkPorts(r) {
|
|
2370
|
+
const [ws, mcp] = await Promise.all([probePort(WS_PORT), probePort(MCP_PORT)]);
|
|
2371
|
+
if (ws && mcp) {
|
|
2372
|
+
r.pass(`Ports ${WS_PORT} (WebSocket) + ${MCP_PORT} (MCP HTTP) in use`, void 0, { ws, mcp });
|
|
2373
|
+
} else if (!ws && !mcp) {
|
|
2374
|
+
r.fail(
|
|
2375
|
+
`Ports ${WS_PORT} + ${MCP_PORT} not listening \u2014 server not running`,
|
|
2376
|
+
"npm run dev:standalone",
|
|
2377
|
+
{ ws, mcp }
|
|
2378
|
+
);
|
|
2379
|
+
} else {
|
|
2380
|
+
r.warn(
|
|
2381
|
+
`Partial: port ${WS_PORT} ${ws ? "up" : "down"}, port ${MCP_PORT} ${mcp ? "up" : "down"}`,
|
|
2382
|
+
"Server may be starting up or partially crashed",
|
|
2383
|
+
{ ws, mcp }
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
return { ws, mcp };
|
|
2387
|
+
}
|
|
2388
|
+
function httpGet(url, timeoutMs = 3e3) {
|
|
2389
|
+
return new Promise((resolve4) => {
|
|
2390
|
+
const req = request(url, { timeout: timeoutMs }, (res) => {
|
|
2391
|
+
let body = "";
|
|
2392
|
+
res.on("data", (chunk) => {
|
|
2393
|
+
body += chunk;
|
|
2394
|
+
});
|
|
2395
|
+
res.on("end", () => {
|
|
2396
|
+
try {
|
|
2397
|
+
resolve4({ status: res.statusCode, data: JSON.parse(body) });
|
|
2398
|
+
} catch {
|
|
2399
|
+
resolve4({ status: res.statusCode, data: null });
|
|
2400
|
+
}
|
|
2401
|
+
});
|
|
2402
|
+
});
|
|
2403
|
+
req.on("error", (err) => resolve4({ error: err.message }));
|
|
2404
|
+
req.on("timeout", () => {
|
|
2405
|
+
req.destroy();
|
|
2406
|
+
resolve4(null);
|
|
2407
|
+
});
|
|
2408
|
+
req.end();
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
async function checkHealth(r) {
|
|
2412
|
+
const result = await httpGet(`http://127.0.0.1:${MCP_PORT}/health`);
|
|
2413
|
+
if (!result) {
|
|
2414
|
+
r.fail(`Server not responding on 127.0.0.1:${MCP_PORT}`, "npm run dev:standalone");
|
|
2415
|
+
return false;
|
|
2416
|
+
}
|
|
2417
|
+
if (result.error) {
|
|
2418
|
+
r.fail(
|
|
2419
|
+
`Server not responding on 127.0.0.1:${MCP_PORT} (${result.error})`,
|
|
2420
|
+
"npm run dev:standalone"
|
|
2421
|
+
);
|
|
2422
|
+
return false;
|
|
2423
|
+
}
|
|
2424
|
+
if (result.status !== 200) {
|
|
2425
|
+
r.fail(`/health returned status ${result.status}`);
|
|
2426
|
+
return false;
|
|
2427
|
+
}
|
|
2428
|
+
const d = result.data;
|
|
2429
|
+
if (d) {
|
|
2430
|
+
const session = d.hasSession ? "session active" : "no MCP session";
|
|
2431
|
+
r.pass(`Server healthy (v${d.version}, ${d.transport}, ${session})`, void 0, {
|
|
2432
|
+
version: d.version,
|
|
2433
|
+
transport: d.transport,
|
|
2434
|
+
hasSession: !!d.hasSession
|
|
2435
|
+
});
|
|
2436
|
+
if (!d.hasSession) {
|
|
2437
|
+
r.warn("No active MCP session \u2014 Claude Code hasn't connected yet");
|
|
2438
|
+
}
|
|
2439
|
+
} else {
|
|
2440
|
+
r.pass("Server responded on /health (could not parse body)");
|
|
2441
|
+
}
|
|
2442
|
+
return true;
|
|
2443
|
+
}
|
|
2444
|
+
function checkSseEndpoint(r) {
|
|
2445
|
+
return new Promise((resolve4) => {
|
|
2446
|
+
const req = request(`http://127.0.0.1:${MCP_PORT}/api/events`, { timeout: 2e3 }, (res) => {
|
|
2447
|
+
req.destroy();
|
|
2448
|
+
const ct = res.headers["content-type"] || "";
|
|
2449
|
+
if (res.statusCode === 200 && ct.includes("text/event-stream")) {
|
|
2450
|
+
r.pass("SSE event stream reachable (/api/events)");
|
|
2451
|
+
} else {
|
|
2452
|
+
r.warn(`/api/events responded with status ${res.statusCode}, content-type: ${ct}`);
|
|
2453
|
+
}
|
|
2454
|
+
resolve4();
|
|
2455
|
+
});
|
|
2456
|
+
req.on("error", (err) => {
|
|
2457
|
+
r.warn(`/api/events not reachable: ${err.message}`);
|
|
2458
|
+
resolve4();
|
|
2459
|
+
});
|
|
2460
|
+
req.on("timeout", () => {
|
|
2461
|
+
req.destroy();
|
|
2462
|
+
r.warn("/api/events timed out");
|
|
2463
|
+
resolve4();
|
|
2464
|
+
});
|
|
2465
|
+
req.end();
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
function resolveAppDataDir2() {
|
|
2469
|
+
const override = process.env.TANDEM_APP_DATA_DIR;
|
|
2470
|
+
if (override && override.length > 0) return override;
|
|
2471
|
+
const home = homedir2();
|
|
2472
|
+
switch (platform()) {
|
|
2473
|
+
case "win32":
|
|
2474
|
+
return join5(process.env.LOCALAPPDATA || join5(home, "AppData", "Local"), "tandem", "Data");
|
|
2475
|
+
case "darwin":
|
|
2476
|
+
return join5(home, "Library", "Application Support", "tandem");
|
|
2477
|
+
default:
|
|
2478
|
+
return join5(process.env.XDG_DATA_HOME || join5(home, ".local", "share"), "tandem");
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
function isPidLive(pid) {
|
|
2482
|
+
try {
|
|
2483
|
+
process.kill(pid, 0);
|
|
2484
|
+
return true;
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
return err?.code === "EPERM";
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
function formatBytes(bytes) {
|
|
2490
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
2491
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
2492
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
2493
|
+
}
|
|
2494
|
+
function checkAnnotationStore(r) {
|
|
2495
|
+
const dir = join5(resolveAppDataDir2(), "annotations");
|
|
2496
|
+
if (!existsSync3(dir)) {
|
|
2497
|
+
r.pass(`Annotation store dir not yet created (${dir}) \u2014 first open will create it`, void 0, {
|
|
2498
|
+
dir,
|
|
2499
|
+
docCount: 0,
|
|
2500
|
+
totalBytes: 0,
|
|
2501
|
+
corruptCount: 0,
|
|
2502
|
+
exists: false
|
|
2503
|
+
});
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
let entries;
|
|
2507
|
+
try {
|
|
2508
|
+
entries = readdirSync2(dir);
|
|
2509
|
+
} catch (err) {
|
|
2510
|
+
r.fail(`Annotation store dir unreadable: ${errMsg(err)}`, `Check permissions on ${dir}`);
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
const jsonFiles = entries.filter((f) => f.endsWith(".json") && !f.endsWith(".corrupt.json"));
|
|
2514
|
+
const corruptFiles = entries.filter((f) => f.includes(".corrupt."));
|
|
2515
|
+
let totalBytes = 0;
|
|
2516
|
+
let newest = { name: null, mtime: 0 };
|
|
2517
|
+
let sampleSchemaVersion = null;
|
|
2518
|
+
for (const f of jsonFiles) {
|
|
2519
|
+
try {
|
|
2520
|
+
const s = statSync2(join5(dir, f));
|
|
2521
|
+
totalBytes += s.size;
|
|
2522
|
+
if (s.mtimeMs > newest.mtime) {
|
|
2523
|
+
newest = { name: f, mtime: s.mtimeMs };
|
|
2524
|
+
}
|
|
2525
|
+
if (sampleSchemaVersion === null) {
|
|
2526
|
+
try {
|
|
2527
|
+
const parsed = JSON.parse(readFileSync3(join5(dir, f), "utf-8"));
|
|
2528
|
+
if (typeof parsed?.schemaVersion === "number") {
|
|
2529
|
+
sampleSchemaVersion = parsed.schemaVersion;
|
|
2530
|
+
}
|
|
2531
|
+
} catch {
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
} catch {
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
r.pass(
|
|
2538
|
+
`Annotation store: ${jsonFiles.length} doc(s), ${formatBytes(totalBytes)} total`,
|
|
2539
|
+
void 0,
|
|
2540
|
+
{
|
|
2541
|
+
dir,
|
|
2542
|
+
docCount: jsonFiles.length,
|
|
2543
|
+
totalBytes,
|
|
2544
|
+
corruptCount: corruptFiles.length
|
|
2545
|
+
}
|
|
2546
|
+
);
|
|
2547
|
+
if (newest.name) {
|
|
2548
|
+
const ageMs = Date.now() - newest.mtime;
|
|
2549
|
+
const ageStr = ageMs < 6e4 ? `${Math.floor(ageMs / 1e3)}s` : `${Math.floor(ageMs / 6e4)}m`;
|
|
2550
|
+
r.pass(`Most recent annotation write: ${newest.name} (${ageStr} ago)`, void 0, {
|
|
2551
|
+
name: newest.name,
|
|
2552
|
+
mtimeMs: newest.mtime,
|
|
2553
|
+
ageMs
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
if (sampleSchemaVersion !== null) {
|
|
2557
|
+
r.pass(`Annotation schema version: ${sampleSchemaVersion}`, void 0, {
|
|
2558
|
+
schemaVersion: sampleSchemaVersion
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
if (corruptFiles.length > 0) {
|
|
2562
|
+
r.warn(
|
|
2563
|
+
`${corruptFiles.length} quarantined annotation file(s) in ${dir}`,
|
|
2564
|
+
"Safe to delete after inspection; kept 7d by design.",
|
|
2565
|
+
{
|
|
2566
|
+
corruptCount: corruptFiles.length,
|
|
2567
|
+
dir
|
|
2568
|
+
}
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
const lockPath = join5(dir, "store.lock");
|
|
2572
|
+
if (!existsSync3(lockPath)) {
|
|
2573
|
+
r.pass("Annotation store lock: not held (no running writer)", void 0, { lockHeld: false });
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
try {
|
|
2577
|
+
const raw = readFileSync3(lockPath, "utf-8").trim();
|
|
2578
|
+
const pid = Number.parseInt(raw, 10);
|
|
2579
|
+
if (!Number.isFinite(pid)) {
|
|
2580
|
+
r.warn(
|
|
2581
|
+
`Annotation store lock at ${lockPath} has unparseable content: "${raw}"`,
|
|
2582
|
+
"Restart Tandem or delete the lock file if no server is running.",
|
|
2583
|
+
{ lockHeld: true, lockPath, lockContent: raw }
|
|
2584
|
+
);
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
if (isPidLive(pid)) {
|
|
2588
|
+
r.pass(`Annotation store lock held by live PID ${pid}`, void 0, {
|
|
2589
|
+
lockHeld: true,
|
|
2590
|
+
pid,
|
|
2591
|
+
pidLive: true
|
|
2592
|
+
});
|
|
2593
|
+
} else {
|
|
2594
|
+
r.warn(
|
|
2595
|
+
`Annotation store lock at ${lockPath} points to dead PID ${pid}`,
|
|
2596
|
+
"The next server start will reclaim the stale lock automatically.",
|
|
2597
|
+
{ lockHeld: true, pid, pidLive: false }
|
|
2598
|
+
);
|
|
2599
|
+
}
|
|
2600
|
+
} catch (err) {
|
|
2601
|
+
r.warn(`Could not read annotation store lock: ${errMsg(err)}`);
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
function errMsg(err) {
|
|
2605
|
+
return err instanceof Error ? err.message : String(err);
|
|
2606
|
+
}
|
|
2607
|
+
async function runDoctor() {
|
|
2608
|
+
const r = new Recorder();
|
|
2609
|
+
await r.check("node-version", () => checkNodeVersion(r));
|
|
2610
|
+
await r.check("node-modules", () => checkNodeModules(r));
|
|
2611
|
+
await r.check("mcp-json", () => checkMcpJson(r));
|
|
2612
|
+
await r.check("user-mcp-config", () => checkUserMcpConfig(r));
|
|
2613
|
+
await r.check("annotation-store", () => checkAnnotationStore(r));
|
|
2614
|
+
const { mcp } = await r.check("ports", () => checkPorts(r));
|
|
2615
|
+
if (mcp) {
|
|
2616
|
+
const healthy = await r.check("health", () => checkHealth(r));
|
|
2617
|
+
if (healthy) {
|
|
2618
|
+
await r.check("sse", () => checkSseEndpoint(r));
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
const summary = r.failures > 0 ? `${r.failures} issue(s) found.` : r.warnings > 0 ? `${r.warnings} warning(s) \u2014 Tandem should work, but check the items above.` : "All checks passed. Tandem is ready.";
|
|
2622
|
+
return {
|
|
2623
|
+
ok: r.failures === 0,
|
|
2624
|
+
crashed: false,
|
|
2625
|
+
failures: r.failures,
|
|
2626
|
+
warnings: r.warnings,
|
|
2627
|
+
summary,
|
|
2628
|
+
error: null,
|
|
2629
|
+
results: r.results
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
function colorTag(status) {
|
|
2633
|
+
switch (status) {
|
|
2634
|
+
case "pass":
|
|
2635
|
+
return "\x1B[32m[PASS]\x1B[0m";
|
|
2636
|
+
case "warn":
|
|
2637
|
+
return "\x1B[33m[WARN]\x1B[0m";
|
|
2638
|
+
case "fail":
|
|
2639
|
+
return "\x1B[31m[FAIL]\x1B[0m";
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
async function runDoctorCli(opts = {}) {
|
|
2643
|
+
const json = opts.json ?? false;
|
|
2644
|
+
let report;
|
|
2645
|
+
try {
|
|
2646
|
+
report = await runDoctor();
|
|
2647
|
+
} catch (err) {
|
|
2648
|
+
const message = errMsg(err);
|
|
2649
|
+
if (json) {
|
|
2650
|
+
const crashed = {
|
|
2651
|
+
ok: false,
|
|
2652
|
+
crashed: true,
|
|
2653
|
+
failures: 0,
|
|
2654
|
+
warnings: 0,
|
|
2655
|
+
summary: `Tandem Doctor crashed unexpectedly: ${message}`,
|
|
2656
|
+
error: message,
|
|
2657
|
+
results: []
|
|
2658
|
+
};
|
|
2659
|
+
process.stdout.write(`${JSON.stringify(crashed, null, 2)}
|
|
2660
|
+
`);
|
|
2661
|
+
} else {
|
|
2662
|
+
process.stderr.write(`
|
|
2663
|
+
Tandem Doctor crashed unexpectedly: ${message}
|
|
2664
|
+
`);
|
|
2665
|
+
process.stderr.write(
|
|
2666
|
+
" Please report this at https://github.com/bloknayrb/tandem/issues\n\n"
|
|
2667
|
+
);
|
|
2668
|
+
}
|
|
2669
|
+
return 2;
|
|
2670
|
+
}
|
|
2671
|
+
const exitCode = report.failures > 0 ? 1 : 0;
|
|
2672
|
+
if (json) {
|
|
2673
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}
|
|
2674
|
+
`);
|
|
2675
|
+
return exitCode;
|
|
2676
|
+
}
|
|
2677
|
+
const out = (line) => process.stdout.write(`${line}
|
|
2678
|
+
`);
|
|
2679
|
+
out("");
|
|
2680
|
+
out(" Tandem Doctor");
|
|
2681
|
+
out(" =============");
|
|
2682
|
+
out("");
|
|
2683
|
+
for (const res of report.results) {
|
|
2684
|
+
out(` ${colorTag(res.status)} ${res.message}`);
|
|
2685
|
+
if (res.status !== "pass" && res.fix) {
|
|
2686
|
+
out(` Fix: ${res.fix}`);
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
out("");
|
|
2690
|
+
if (report.failures > 0) {
|
|
2691
|
+
out(` ${report.failures} issue(s) found. Fix the items above and re-run: tandem doctor`);
|
|
2692
|
+
} else if (report.warnings > 0) {
|
|
2693
|
+
out(` ${report.warnings} warning(s) \u2014 Tandem should work, but check the items above.`);
|
|
2694
|
+
} else {
|
|
2695
|
+
out(" All checks passed. Tandem is ready.");
|
|
2696
|
+
}
|
|
2697
|
+
out("");
|
|
2698
|
+
return exitCode;
|
|
2699
|
+
}
|
|
2700
|
+
var WS_PORT, MCP_PORT, Recorder;
|
|
2701
|
+
var init_doctor = __esm({
|
|
2702
|
+
"src/cli/doctor.ts"() {
|
|
2703
|
+
"use strict";
|
|
2704
|
+
init_constants();
|
|
2705
|
+
WS_PORT = DEFAULT_WS_PORT;
|
|
2706
|
+
MCP_PORT = DEFAULT_MCP_PORT;
|
|
2707
|
+
Recorder = class {
|
|
2708
|
+
failures = 0;
|
|
2709
|
+
warnings = 0;
|
|
2710
|
+
results = [];
|
|
2711
|
+
currentCheck = "";
|
|
2712
|
+
async check(name, fn) {
|
|
2713
|
+
const prev = this.currentCheck;
|
|
2714
|
+
this.currentCheck = name;
|
|
2715
|
+
try {
|
|
2716
|
+
return await fn();
|
|
2717
|
+
} finally {
|
|
2718
|
+
this.currentCheck = prev;
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
record(status, msg, fix, fields) {
|
|
2722
|
+
const entry = { check: this.currentCheck, status, message: msg };
|
|
2723
|
+
if (fix) entry.fix = fix;
|
|
2724
|
+
if (fields) entry.data = fields;
|
|
2725
|
+
this.results.push(entry);
|
|
2726
|
+
}
|
|
2727
|
+
pass(msg, fix, fields) {
|
|
2728
|
+
this.record("pass", msg, fix, fields);
|
|
2729
|
+
}
|
|
2730
|
+
warn(msg, fix, fields) {
|
|
2731
|
+
this.warnings++;
|
|
2732
|
+
this.record("warn", msg, fix, fields);
|
|
2733
|
+
}
|
|
2734
|
+
fail(msg, fix, fields) {
|
|
2735
|
+
this.failures++;
|
|
2736
|
+
this.record("fail", msg, fix, fields);
|
|
2737
|
+
}
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
});
|
|
2741
|
+
|
|
2231
2742
|
// src/shared/auth/token-file.ts
|
|
2232
2743
|
import envPaths2 from "env-paths";
|
|
2233
2744
|
import fs2 from "fs";
|
|
@@ -2396,11 +2907,11 @@ __export(start_exports, {
|
|
|
2396
2907
|
runStart: () => runStart
|
|
2397
2908
|
});
|
|
2398
2909
|
import { spawn } from "child_process";
|
|
2399
|
-
import { existsSync as
|
|
2910
|
+
import { existsSync as existsSync4 } from "fs";
|
|
2400
2911
|
import { dirname as dirname3, resolve as resolve3 } from "path";
|
|
2401
2912
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
2402
2913
|
function runStart() {
|
|
2403
|
-
if (!
|
|
2914
|
+
if (!existsSync4(SERVER_DIST)) {
|
|
2404
2915
|
console.error(`[Tandem] Server not found at ${SERVER_DIST}`);
|
|
2405
2916
|
console.error("[Tandem] The installation may be corrupted. Try: npm install -g tandem-editor");
|
|
2406
2917
|
process.exit(1);
|
|
@@ -2451,7 +2962,7 @@ process.once("unhandledRejection", (reason) => {
|
|
|
2451
2962
|
`);
|
|
2452
2963
|
process.exit(1);
|
|
2453
2964
|
});
|
|
2454
|
-
var version = true ? "0.13.
|
|
2965
|
+
var version = true ? "0.13.6" : "0.0.0-dev";
|
|
2455
2966
|
var args = process.argv.slice(2);
|
|
2456
2967
|
var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
|
|
2457
2968
|
if (!isStdioMode) {
|
|
@@ -2465,6 +2976,9 @@ Usage:
|
|
|
2465
2976
|
tandem setup Register MCP tools with your AI client (Claude Code / Claude Desktop by default)
|
|
2466
2977
|
tandem setup --force Register to default paths regardless of detection
|
|
2467
2978
|
tandem setup --with-channel-shim Also register the stdio channel shim (legacy opt-in)
|
|
2979
|
+
tandem doctor Diagnose setup issues (Node version, .mcp.json,
|
|
2980
|
+
ports, server health, annotation store)
|
|
2981
|
+
tandem doctor --json Same checks, emit a single JSON report on stdout
|
|
2468
2982
|
tandem rotate-token Rotate the auth token with a 60-second grace window
|
|
2469
2983
|
tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP
|
|
2470
2984
|
(used by the plugin's Cowork bridge; requires
|
|
@@ -2497,6 +3011,10 @@ try {
|
|
|
2497
3011
|
} else if (args[0] === "channel") {
|
|
2498
3012
|
const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
|
|
2499
3013
|
await runChannelCli2();
|
|
3014
|
+
} else if (args[0] === "doctor") {
|
|
3015
|
+
const { runDoctorCli: runDoctorCli2 } = await Promise.resolve().then(() => (init_doctor(), doctor_exports));
|
|
3016
|
+
const exitCode = await runDoctorCli2({ json: args.includes("--json") });
|
|
3017
|
+
process.exit(exitCode);
|
|
2500
3018
|
} else if (args[0] === "rotate-token") {
|
|
2501
3019
|
const { rotateToken: rotateToken2 } = await Promise.resolve().then(() => (init_rotate_token(), rotate_token_exports));
|
|
2502
3020
|
await rotateToken2();
|