tandem-editor 0.11.2 → 0.13.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 +2 -2
- package/.claude-plugin/plugin.json +3 -3
- package/CHANGELOG.md +201 -72
- package/README.md +141 -238
- package/dist/channel/index.js +211 -81
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +749 -170
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/CoworkSettings-BOYbyKul.js +3 -0
- package/dist/client/assets/event-CNdo2oXa.js +1 -0
- package/dist/client/assets/index-D8uS4cj7.css +1 -0
- package/dist/client/assets/index-Dm_QtxGQ.js +1 -0
- package/dist/client/assets/index-g-KwmRn9.js +271 -0
- package/dist/client/assets/webview-KiZyy_pC.js +1 -0
- package/dist/client/assets/window-DePn7tLG.js +1 -0
- package/dist/client/fonts/OFL-Hanuman.txt +93 -0
- package/dist/client/fonts/OFL-InterTight.txt +93 -0
- package/dist/client/fonts/OFL-JetBrainsMono.txt +93 -0
- package/dist/client/fonts/OFL-SNPro.txt +93 -0
- package/dist/client/fonts/OFL-Sono.txt +93 -0
- package/dist/client/fonts/OFL-SourceSerif4.txt +93 -0
- package/dist/client/fonts/hanuman-latin.woff2 +0 -0
- package/dist/client/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/dist/client/fonts/sn-pro-latin.woff2 +0 -0
- package/dist/client/fonts/sono-latin.woff2 +0 -0
- package/dist/client/fonts/source-serif-4-latin.woff2 +0 -0
- package/dist/client/index.html +206 -17
- package/dist/client/logo.png +0 -0
- package/dist/monitor/index.js +241 -160
- package/dist/monitor/index.js.map +1 -1
- package/dist/server/index.js +22828 -19659
- package/dist/server/index.js.map +1 -1
- package/package.json +12 -4
- package/sample/welcome.md +6 -6
- package/skills/tandem/SKILL.md +15 -0
- package/dist/client/assets/CoworkSettings-DK3jjdwK.js +0 -3
- package/dist/client/assets/index-CfT503n4.js +0 -297
- package/dist/client/assets/index-DeJe09pn.css +0 -1
- package/dist/client/assets/webview-Ben21ZLJ.js +0 -1
- package/dist/client/assets/window-BxBvHL5k.js +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -355,8 +355,22 @@ var init_uninstall_scrub = __esm({
|
|
|
355
355
|
}
|
|
356
356
|
});
|
|
357
357
|
|
|
358
|
+
// src/cli/skill-content.ts
|
|
359
|
+
import { readFileSync } from "fs";
|
|
360
|
+
import { dirname, resolve } from "path";
|
|
361
|
+
import { fileURLToPath } from "url";
|
|
362
|
+
var __dirname, SKILL_PATH, SKILL_CONTENT;
|
|
363
|
+
var init_skill_content = __esm({
|
|
364
|
+
"src/cli/skill-content.ts"() {
|
|
365
|
+
"use strict";
|
|
366
|
+
__dirname = dirname(fileURLToPath(import.meta.url));
|
|
367
|
+
SKILL_PATH = resolve(__dirname, "../../skills/tandem/SKILL.md");
|
|
368
|
+
SKILL_CONTENT = readFileSync(SKILL_PATH, "utf-8");
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
358
372
|
// src/shared/constants.ts
|
|
359
|
-
var DEFAULT_MCP_PORT, TANDEM_REPO_URL, TANDEM_ISSUES_NEW_URL, MAX_FILE_SIZE,
|
|
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;
|
|
360
374
|
var init_constants = __esm({
|
|
361
375
|
"src/shared/constants.ts"() {
|
|
362
376
|
"use strict";
|
|
@@ -364,9 +378,8 @@ var init_constants = __esm({
|
|
|
364
378
|
TANDEM_REPO_URL = "https://github.com/bloknayrb/tandem";
|
|
365
379
|
TANDEM_ISSUES_NEW_URL = `${TANDEM_REPO_URL}/issues/new`;
|
|
366
380
|
MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
367
|
-
MAX_WS_PAYLOAD = 10 * 1024 * 1024;
|
|
368
|
-
IDLE_TIMEOUT = 30 * 60 * 1e3;
|
|
369
381
|
SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
|
|
382
|
+
TANDEM_MODE_DEFAULT = "tandem";
|
|
370
383
|
CHANNEL_MAX_RETRIES = 5;
|
|
371
384
|
CHANNEL_RETRY_DELAY_MS = 2e3;
|
|
372
385
|
CHANNEL_CONNECT_FETCH_TIMEOUT_MS = 1e4;
|
|
@@ -381,37 +394,236 @@ var init_constants = __esm({
|
|
|
381
394
|
}
|
|
382
395
|
});
|
|
383
396
|
|
|
384
|
-
// src/
|
|
385
|
-
import
|
|
386
|
-
import
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
"
|
|
397
|
+
// src/server/platform.ts
|
|
398
|
+
import envPaths from "env-paths";
|
|
399
|
+
import path3 from "path";
|
|
400
|
+
function resolveAppDataDir() {
|
|
401
|
+
const envOverride = process.env.TANDEM_APP_DATA_DIR;
|
|
402
|
+
if (envOverride && envOverride.length > 0) return envOverride;
|
|
403
|
+
return envPaths("tandem", { suffix: "" }).data;
|
|
404
|
+
}
|
|
405
|
+
var APP_DATA_DIR, SESSION_DIR, LAST_SEEN_VERSION_FILE;
|
|
406
|
+
var init_platform = __esm({
|
|
407
|
+
"src/server/platform.ts"() {
|
|
391
408
|
"use strict";
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
409
|
+
APP_DATA_DIR = resolveAppDataDir();
|
|
410
|
+
SESSION_DIR = path3.join(APP_DATA_DIR, "sessions");
|
|
411
|
+
LAST_SEEN_VERSION_FILE = path3.join(APP_DATA_DIR, "last-seen-version");
|
|
395
412
|
}
|
|
396
413
|
});
|
|
397
414
|
|
|
398
|
-
// src/
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
415
|
+
// src/server/integrations/acl-win.ts
|
|
416
|
+
import { execFile as execFile2 } from "child_process";
|
|
417
|
+
import { join } from "path";
|
|
418
|
+
import { promisify as promisify2 } from "util";
|
|
419
|
+
function systemBin(name) {
|
|
420
|
+
return join(process.env.SystemRoot ?? "C:\\Windows", "System32", name);
|
|
421
|
+
}
|
|
422
|
+
async function runPowerShell(script, env) {
|
|
423
|
+
const args2 = ["-NoProfile", "-NonInteractive", "-Command", script];
|
|
424
|
+
try {
|
|
425
|
+
return await execFileAsync2("pwsh.exe", args2, { env });
|
|
426
|
+
} catch (err) {
|
|
427
|
+
const code = err.code;
|
|
428
|
+
if (code !== "ENOENT") throw err;
|
|
429
|
+
try {
|
|
430
|
+
return await execFileAsync2("powershell.exe", args2, { env });
|
|
431
|
+
} catch (fallbackErr) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`runPowerShell: both pwsh.exe and powershell.exe failed (pwsh: ${err.message})`,
|
|
434
|
+
{ cause: fallbackErr }
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async function getCurrentUserSid() {
|
|
440
|
+
if (cachedCurrentUserSid !== null) return cachedCurrentUserSid;
|
|
441
|
+
const { stdout } = await execFileAsync2(systemBin("whoami.exe"), ["/user", "/fo", "csv", "/nh"]);
|
|
442
|
+
const match = stdout.match(/"(S-[\d-]+)"\s*$/m);
|
|
443
|
+
if (!match) {
|
|
444
|
+
throw new Error(`getCurrentUserSid: could not parse SID from whoami output: ${stdout.trim()}`);
|
|
445
|
+
}
|
|
446
|
+
cachedCurrentUserSid = match[1];
|
|
447
|
+
return cachedCurrentUserSid;
|
|
448
|
+
}
|
|
449
|
+
async function setRestrictiveAcl(path6) {
|
|
450
|
+
if (process.platform !== "win32") return;
|
|
451
|
+
const sid = await getCurrentUserSid();
|
|
452
|
+
try {
|
|
453
|
+
await execFileAsync2(systemBin("icacls.exe"), [path6, "/inheritance:r", "/grant:r", `*${sid}:F`]);
|
|
454
|
+
} catch (err) {
|
|
455
|
+
throw new Error(`setRestrictiveAcl: icacls failed on ${path6}: ${err.message}`, {
|
|
456
|
+
cause: err
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
await assertNoBroadAce(path6);
|
|
460
|
+
}
|
|
461
|
+
async function assertNoBroadAce(path6) {
|
|
462
|
+
if (process.platform !== "win32") return;
|
|
463
|
+
const script = "Import-Module Microsoft.PowerShell.Security; (Get-Acl -LiteralPath $env:TANDEM_ACL_PATH).Sddl";
|
|
464
|
+
const { stdout } = await runPowerShell(script, {
|
|
465
|
+
...process.env,
|
|
466
|
+
TANDEM_ACL_PATH: path6
|
|
467
|
+
});
|
|
468
|
+
const sddl = stdout.trim();
|
|
469
|
+
for (const fragment of BROAD_SDDL_FRAGMENTS) {
|
|
470
|
+
if (sddl.includes(fragment)) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`assertNoBroadAce: ${path6} has a broad-principal ACE (SDDL fragment ${fragment}). SDDL:
|
|
473
|
+
${sddl}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
var execFileAsync2, BROAD_SDDL_FRAGMENTS, cachedCurrentUserSid;
|
|
479
|
+
var init_acl_win = __esm({
|
|
480
|
+
"src/server/integrations/acl-win.ts"() {
|
|
481
|
+
"use strict";
|
|
482
|
+
execFileAsync2 = promisify2(execFile2);
|
|
483
|
+
BROAD_SDDL_FRAGMENTS = [
|
|
484
|
+
";WD)",
|
|
485
|
+
// Everyone (S-1-1-0)
|
|
486
|
+
";AU)",
|
|
487
|
+
// Authenticated Users (S-1-5-11)
|
|
488
|
+
";BU)"
|
|
489
|
+
// BUILTIN\Users (S-1-5-32-545)
|
|
490
|
+
];
|
|
491
|
+
cachedCurrentUserSid = null;
|
|
492
|
+
}
|
|
408
493
|
});
|
|
494
|
+
|
|
495
|
+
// src/server/integrations/backup.ts
|
|
409
496
|
import { randomUUID } from "crypto";
|
|
410
|
-
import {
|
|
411
|
-
import {
|
|
412
|
-
|
|
413
|
-
|
|
497
|
+
import { open, readdir, rm } from "fs/promises";
|
|
498
|
+
import { join as join2 } from "path";
|
|
499
|
+
function backupDir(appDataDir) {
|
|
500
|
+
return join2(appDataDir, BACKUP_DIR_NAME);
|
|
501
|
+
}
|
|
502
|
+
function formatTimestamp(d) {
|
|
503
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
504
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
505
|
+
}
|
|
506
|
+
function backupFilename(now = /* @__PURE__ */ new Date()) {
|
|
507
|
+
const ts = formatTimestamp(now);
|
|
508
|
+
const uuid8 = randomUUID().slice(0, 8);
|
|
509
|
+
return `${BACKUP_PREFIX}${ts}-${uuid8}${BACKUP_SUFFIX}`;
|
|
510
|
+
}
|
|
511
|
+
async function writeBackup(dir, content) {
|
|
512
|
+
const backupPath = join2(dir, backupFilename());
|
|
513
|
+
const fd = await open(backupPath, "wx", 384);
|
|
514
|
+
let writeFailed = false;
|
|
515
|
+
try {
|
|
516
|
+
try {
|
|
517
|
+
await fd.write(content);
|
|
518
|
+
} catch (writeErr) {
|
|
519
|
+
writeFailed = true;
|
|
520
|
+
throw writeErr;
|
|
521
|
+
}
|
|
522
|
+
} finally {
|
|
523
|
+
await fd.close();
|
|
524
|
+
if (writeFailed) {
|
|
525
|
+
await rm(backupPath, { force: true }).catch(() => {
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (process.platform === "win32") {
|
|
530
|
+
try {
|
|
531
|
+
await setRestrictiveAcl(backupPath);
|
|
532
|
+
} catch (aclErr) {
|
|
533
|
+
await rm(backupPath, { force: true }).catch(() => {
|
|
534
|
+
});
|
|
535
|
+
throw aclErr;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return backupPath;
|
|
539
|
+
}
|
|
540
|
+
async function listBackups(dir, prefix = BACKUP_PREFIX, suffix = BACKUP_SUFFIX) {
|
|
541
|
+
let entries;
|
|
542
|
+
try {
|
|
543
|
+
entries = await readdir(dir);
|
|
544
|
+
} catch (err) {
|
|
545
|
+
if (err.code === "ENOENT") return [];
|
|
546
|
+
throw err;
|
|
547
|
+
}
|
|
548
|
+
return entries.filter((e) => e.startsWith(prefix) && e.endsWith(suffix)).sort().reverse();
|
|
549
|
+
}
|
|
550
|
+
async function pruneOldBackups(dir, prefix = BACKUP_PREFIX, suffix = BACKUP_SUFFIX, max = MAX_BACKUPS) {
|
|
551
|
+
const all = await listBackups(dir, prefix, suffix);
|
|
552
|
+
const toRemove = all.slice(max);
|
|
553
|
+
const failures = [];
|
|
554
|
+
for (const name of toRemove) {
|
|
555
|
+
const fullPath = join2(dir, name);
|
|
556
|
+
try {
|
|
557
|
+
await rm(fullPath, { force: true });
|
|
558
|
+
} catch (err) {
|
|
559
|
+
failures.push({ path: fullPath, err });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (failures.length > 0) {
|
|
563
|
+
const summary = failures.map((f) => `${f.path}: ${f.err instanceof Error ? f.err.message : String(f.err)}`).join("; ");
|
|
564
|
+
console.error(
|
|
565
|
+
`[tandem] backup sweep: ${failures.length} entries could not be removed (${summary})`
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
return toRemove.map((name) => join2(dir, name));
|
|
569
|
+
}
|
|
570
|
+
function shouldBackup(existing, newEntry) {
|
|
571
|
+
if (existing == null) return false;
|
|
572
|
+
return canonicalJson(existing) !== canonicalJson(newEntry);
|
|
573
|
+
}
|
|
574
|
+
function canonicalJson(value) {
|
|
575
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
576
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
577
|
+
const keys = Object.keys(value).sort();
|
|
578
|
+
const parts = keys.map(
|
|
579
|
+
(k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`
|
|
580
|
+
);
|
|
581
|
+
return `{${parts.join(",")}}`;
|
|
582
|
+
}
|
|
583
|
+
var BACKUP_DIR_NAME, BACKUP_PREFIX, BACKUP_SUFFIX, MAX_BACKUPS;
|
|
584
|
+
var init_backup = __esm({
|
|
585
|
+
"src/server/integrations/backup.ts"() {
|
|
586
|
+
"use strict";
|
|
587
|
+
init_acl_win();
|
|
588
|
+
BACKUP_DIR_NAME = ".backups";
|
|
589
|
+
BACKUP_PREFIX = "claude-json-";
|
|
590
|
+
BACKUP_SUFFIX = ".json";
|
|
591
|
+
MAX_BACKUPS = 3;
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// src/server/integrations/apply.ts
|
|
596
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
597
|
+
import {
|
|
598
|
+
chmodSync,
|
|
599
|
+
existsSync,
|
|
600
|
+
constants as fsConstants,
|
|
601
|
+
lstatSync,
|
|
602
|
+
mkdirSync,
|
|
603
|
+
readdirSync,
|
|
604
|
+
readFileSync as readFileSync2,
|
|
605
|
+
realpathSync,
|
|
606
|
+
statSync
|
|
607
|
+
} from "fs";
|
|
608
|
+
import {
|
|
609
|
+
chmod,
|
|
610
|
+
copyFile,
|
|
611
|
+
mkdir,
|
|
612
|
+
open as open2,
|
|
613
|
+
readFile,
|
|
614
|
+
rename,
|
|
615
|
+
unlink,
|
|
616
|
+
writeFile
|
|
617
|
+
} from "fs/promises";
|
|
618
|
+
import { homedir, tmpdir } from "os";
|
|
619
|
+
import { basename, dirname as dirname2, join as join3, resolve as resolve2, sep } from "path";
|
|
414
620
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
621
|
+
function applyOpsForCli(create, opts) {
|
|
622
|
+
return {
|
|
623
|
+
create,
|
|
624
|
+
remove: opts.withChannelShim ? [] : ["tandem-channel"]
|
|
625
|
+
};
|
|
626
|
+
}
|
|
415
627
|
function buildMcpEntries(channelPath, opts = {}) {
|
|
416
628
|
const isDesktop = opts.targetKind === "claude-desktop";
|
|
417
629
|
let tandemEntry;
|
|
@@ -445,20 +657,66 @@ function buildMcpEntries(channelPath, opts = {}) {
|
|
|
445
657
|
}
|
|
446
658
|
return entries;
|
|
447
659
|
}
|
|
660
|
+
function realpathCached(p) {
|
|
661
|
+
const cached = DEFAULT_ROOTS_CACHE.get(p);
|
|
662
|
+
if (cached !== void 0) return cached;
|
|
663
|
+
try {
|
|
664
|
+
const r = realpathSync(p);
|
|
665
|
+
DEFAULT_ROOTS_CACHE.set(p, r);
|
|
666
|
+
return r;
|
|
667
|
+
} catch {
|
|
668
|
+
return p;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function assertPathSafe(targetPath, opts = {}) {
|
|
672
|
+
const allowedRoots = (opts.allowedRoots ?? [homedir(), tmpdir()]).map(realpathCached);
|
|
673
|
+
let cursor = targetPath;
|
|
674
|
+
let existing = null;
|
|
675
|
+
while (true) {
|
|
676
|
+
if (existsSync(cursor)) {
|
|
677
|
+
const st = lstatSync(cursor);
|
|
678
|
+
if (st.isSymbolicLink()) {
|
|
679
|
+
throw new PathRejectedError(
|
|
680
|
+
targetPath,
|
|
681
|
+
"symlink",
|
|
682
|
+
`Refusing to operate on symlinked path: ${cursor}`
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
existing = cursor;
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
const parent = dirname2(cursor);
|
|
689
|
+
if (parent === cursor) break;
|
|
690
|
+
cursor = parent;
|
|
691
|
+
}
|
|
692
|
+
const resolved = existing ? realpathSync(existing) : targetPath;
|
|
693
|
+
const ok = allowedRoots.some((root) => {
|
|
694
|
+
if (resolved === root) return true;
|
|
695
|
+
const normRoot = root.endsWith(sep) ? root : `${root}${sep}`;
|
|
696
|
+
return resolved.startsWith(normRoot);
|
|
697
|
+
});
|
|
698
|
+
if (!ok) {
|
|
699
|
+
throw new PathRejectedError(
|
|
700
|
+
targetPath,
|
|
701
|
+
"outside-home",
|
|
702
|
+
`Refusing path outside allowed roots: realpath=${resolved}`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
448
706
|
function detectTargets(opts = {}) {
|
|
449
707
|
const home = opts.homeOverride ?? homedir();
|
|
450
708
|
const targets = [];
|
|
451
|
-
const claudeCodeConfig =
|
|
452
|
-
const claudeCodeDir =
|
|
709
|
+
const claudeCodeConfig = join3(home, ".claude.json");
|
|
710
|
+
const claudeCodeDir = join3(home, ".claude");
|
|
453
711
|
if (opts.force || existsSync(claudeCodeConfig) || existsSync(claudeCodeDir)) {
|
|
454
712
|
targets.push({ label: "Claude Code", configPath: claudeCodeConfig, kind: "claude-code" });
|
|
455
713
|
}
|
|
456
714
|
let desktopConfig = null;
|
|
457
715
|
if (process.platform === "win32") {
|
|
458
|
-
const appdata = process.env.APPDATA ??
|
|
459
|
-
desktopConfig =
|
|
716
|
+
const appdata = process.env.APPDATA ?? join3(home, "AppData", "Roaming");
|
|
717
|
+
desktopConfig = join3(appdata, "Claude", "claude_desktop_config.json");
|
|
460
718
|
} else if (process.platform === "darwin") {
|
|
461
|
-
desktopConfig =
|
|
719
|
+
desktopConfig = join3(
|
|
462
720
|
home,
|
|
463
721
|
"Library",
|
|
464
722
|
"Application Support",
|
|
@@ -466,18 +724,24 @@ function detectTargets(opts = {}) {
|
|
|
466
724
|
"claude_desktop_config.json"
|
|
467
725
|
);
|
|
468
726
|
} else {
|
|
469
|
-
desktopConfig =
|
|
727
|
+
desktopConfig = join3(home, ".config", "claude", "claude_desktop_config.json");
|
|
470
728
|
}
|
|
471
729
|
if (desktopConfig && (opts.force || existsSync(desktopConfig))) {
|
|
472
730
|
targets.push({ label: "Claude Desktop", configPath: desktopConfig, kind: "claude-desktop" });
|
|
473
731
|
}
|
|
474
732
|
if (process.platform === "win32") {
|
|
475
|
-
const localAppData = opts.localAppDataOverride ?? process.env.LOCALAPPDATA ??
|
|
476
|
-
|
|
733
|
+
const localAppData = opts.localAppDataOverride ?? process.env.LOCALAPPDATA ?? join3(home, "AppData", "Local");
|
|
734
|
+
try {
|
|
735
|
+
assertPathSafe(localAppData, { allowedRoots: [home] });
|
|
736
|
+
} catch {
|
|
737
|
+
return targets;
|
|
738
|
+
}
|
|
739
|
+
const packagesDir = join3(localAppData, "Packages");
|
|
477
740
|
try {
|
|
478
741
|
const entries = readdirSync(packagesDir);
|
|
479
|
-
|
|
480
|
-
|
|
742
|
+
const matching = entries.filter((n) => MSIX_PACKAGE_PATTERN.test(n));
|
|
743
|
+
for (const pkg of matching) {
|
|
744
|
+
const msixConfig = join3(
|
|
481
745
|
packagesDir,
|
|
482
746
|
pkg,
|
|
483
747
|
"LocalCache",
|
|
@@ -486,7 +750,7 @@ function detectTargets(opts = {}) {
|
|
|
486
750
|
"claude_desktop_config.json"
|
|
487
751
|
);
|
|
488
752
|
if (opts.force || existsSync(msixConfig)) {
|
|
489
|
-
const suffix =
|
|
753
|
+
const suffix = matching.length > 1 ? ` (${pkg.slice(0, 12)}\u2026)` : "";
|
|
490
754
|
targets.push({
|
|
491
755
|
label: `Claude Desktop MSIX${suffix}`,
|
|
492
756
|
configPath: msixConfig,
|
|
@@ -500,37 +764,102 @@ function detectTargets(opts = {}) {
|
|
|
500
764
|
return targets;
|
|
501
765
|
}
|
|
502
766
|
async function atomicWrite(content, dest) {
|
|
503
|
-
const tmp =
|
|
767
|
+
const tmp = join3(dirname2(dest), `.tandem-setup-${randomUUID2()}.tmp`);
|
|
504
768
|
await writeFile(tmp, content, "utf-8");
|
|
769
|
+
try {
|
|
770
|
+
if (process.platform === "win32") {
|
|
771
|
+
await setRestrictiveAcl(tmp);
|
|
772
|
+
} else {
|
|
773
|
+
await chmod(tmp, 384);
|
|
774
|
+
}
|
|
775
|
+
} catch (tightenErr) {
|
|
776
|
+
await unlinkOrLeak(tmp, tightenErr);
|
|
777
|
+
throw tightenErr;
|
|
778
|
+
}
|
|
505
779
|
try {
|
|
506
780
|
await rename(tmp, dest);
|
|
507
781
|
} catch (err) {
|
|
508
782
|
if (err.code === "EXDEV") {
|
|
509
783
|
await copyFile(tmp, dest);
|
|
510
|
-
await
|
|
511
|
-
|
|
512
|
-
|
|
784
|
+
await unlinkOrLeak(tmp, err);
|
|
785
|
+
if (process.platform === "win32") await setRestrictiveAcl(dest);
|
|
786
|
+
else await chmod(dest, 384);
|
|
513
787
|
} else {
|
|
514
|
-
await
|
|
515
|
-
console.error(` Warning: could not remove temp file ${tmp}: ${cleanupErr.message}`);
|
|
516
|
-
});
|
|
788
|
+
await unlinkOrLeak(tmp, err);
|
|
517
789
|
throw err;
|
|
518
790
|
}
|
|
519
791
|
}
|
|
520
792
|
}
|
|
521
|
-
async function
|
|
793
|
+
async function unlinkOrLeak(path6, originalErr) {
|
|
794
|
+
try {
|
|
795
|
+
await unlink(path6);
|
|
796
|
+
} catch (cleanupErr) {
|
|
797
|
+
if (originalErr instanceof Error && originalErr.cause === void 0) {
|
|
798
|
+
originalErr.cause = cleanupErr;
|
|
799
|
+
}
|
|
800
|
+
console.error(
|
|
801
|
+
` Warning: could not remove ${path6} after a previous failure: ${cleanupErr.message}`
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
async function applyConfig(configPath, ops) {
|
|
806
|
+
assertPathSafe(configPath);
|
|
807
|
+
try {
|
|
808
|
+
const { size } = statSync(configPath);
|
|
809
|
+
if (size > MAX_CONFIG_BYTES) {
|
|
810
|
+
throw new Error(
|
|
811
|
+
`${configPath} is ${size} bytes; refusing to read (cap: ${MAX_CONFIG_BYTES}).`
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
} catch (err) {
|
|
815
|
+
if (err.code !== "ENOENT") throw err;
|
|
816
|
+
}
|
|
522
817
|
let existing = {};
|
|
523
818
|
try {
|
|
524
|
-
|
|
819
|
+
let raw = readFileSync2(configPath, "utf-8");
|
|
820
|
+
if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
|
|
821
|
+
const parsed = JSON.parse(raw);
|
|
822
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
823
|
+
throw new Error(`${configPath} root is not a JSON object \u2014 refusing to rewrite`);
|
|
824
|
+
}
|
|
825
|
+
const maybeServers = parsed.mcpServers;
|
|
826
|
+
if (maybeServers !== void 0 && (maybeServers === null || typeof maybeServers !== "object" || Array.isArray(maybeServers))) {
|
|
827
|
+
throw new Error(`${configPath} mcpServers is not an object \u2014 refusing to rewrite`);
|
|
828
|
+
}
|
|
829
|
+
existing = parsed;
|
|
525
830
|
} catch (err) {
|
|
526
831
|
const code = err.code;
|
|
527
832
|
if (code === "ENOENT") {
|
|
528
833
|
} else if (err instanceof SyntaxError) {
|
|
529
|
-
const
|
|
834
|
+
const brokenBackupDir = join3(resolveAppDataDir(), ".broken-backups");
|
|
835
|
+
assertPathSafe(brokenBackupDir);
|
|
836
|
+
mkdirSync(brokenBackupDir, { recursive: true, mode: 448 });
|
|
837
|
+
const backupPath2 = join3(
|
|
838
|
+
brokenBackupDir,
|
|
839
|
+
`${basename(configPath)}.broken-${Date.now()}-${randomUUID2()}`
|
|
840
|
+
);
|
|
530
841
|
try {
|
|
531
|
-
|
|
842
|
+
if (process.platform === "win32") {
|
|
843
|
+
try {
|
|
844
|
+
await setRestrictiveAcl(brokenBackupDir);
|
|
845
|
+
} catch (aclErr) {
|
|
846
|
+
throw new Error(
|
|
847
|
+
`failed to apply restrictive ACL to broken-backups dir ${brokenBackupDir}: ${aclErr instanceof Error ? aclErr.message : String(aclErr)}`,
|
|
848
|
+
{ cause: aclErr }
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
await copyFile(configPath, backupPath2, fsConstants.COPYFILE_EXCL);
|
|
852
|
+
} else {
|
|
853
|
+
const data = await readFile(configPath);
|
|
854
|
+
const fd = await open2(backupPath2, "wx", 384);
|
|
855
|
+
try {
|
|
856
|
+
await fd.write(data);
|
|
857
|
+
} finally {
|
|
858
|
+
await fd.close();
|
|
859
|
+
}
|
|
860
|
+
}
|
|
532
861
|
console.error(
|
|
533
|
-
` Warning: ${configPath} contains malformed JSON \u2014 backed up to ${
|
|
862
|
+
` Warning: ${configPath} contains malformed JSON \u2014 backed up to ${backupPath2}, replacing with fresh config`
|
|
534
863
|
);
|
|
535
864
|
} catch (copyErr) {
|
|
536
865
|
console.error(
|
|
@@ -542,28 +871,61 @@ async function applyConfig(configPath, entries) {
|
|
|
542
871
|
throw err;
|
|
543
872
|
}
|
|
544
873
|
}
|
|
874
|
+
const backupPath = await maybeBackupExistingConfig(configPath, existing, ops);
|
|
875
|
+
if (backupPath && ops.onBackup) {
|
|
876
|
+
try {
|
|
877
|
+
ops.onBackup(backupPath);
|
|
878
|
+
} catch (cbErr) {
|
|
879
|
+
console.error(
|
|
880
|
+
` Warning: onBackup callback threw \u2014 continuing with rewrite: ${cbErr instanceof Error ? cbErr.message : cbErr}`
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
545
884
|
const merged = {
|
|
546
885
|
...existing.mcpServers ?? {},
|
|
547
|
-
...
|
|
886
|
+
...ops.create
|
|
548
887
|
};
|
|
549
|
-
|
|
550
|
-
if (merged[
|
|
551
|
-
console.error(
|
|
552
|
-
` Warning: removed stale tandem-channel entry from ${configPath} (legacy Tauri install artifact)`
|
|
553
|
-
);
|
|
888
|
+
for (const key of ops.remove) {
|
|
889
|
+
if (merged[key]) {
|
|
890
|
+
console.error(` Note: removed mcpServers.${key} from ${configPath}`);
|
|
554
891
|
}
|
|
555
|
-
delete merged[
|
|
892
|
+
delete merged[key];
|
|
556
893
|
}
|
|
557
894
|
const updated = { ...existing, mcpServers: merged };
|
|
558
895
|
await mkdir(dirname2(configPath), { recursive: true });
|
|
559
896
|
await atomicWrite(JSON.stringify(updated, null, 2) + "\n", configPath);
|
|
560
897
|
}
|
|
898
|
+
async function maybeBackupExistingConfig(configPath, existing, ops) {
|
|
899
|
+
const existingTandem = existing.mcpServers?.tandem;
|
|
900
|
+
if (!shouldBackup(existingTandem, ops.create.tandem)) return void 0;
|
|
901
|
+
const dir = backupDir(resolveAppDataDir());
|
|
902
|
+
assertPathSafe(dir);
|
|
903
|
+
mkdirSync(dir, { recursive: true, mode: 448 });
|
|
904
|
+
if (process.platform !== "win32") {
|
|
905
|
+
try {
|
|
906
|
+
const dirStat = statSync(dir);
|
|
907
|
+
if ((dirStat.mode & 511) !== 448) chmodSync(dir, 448);
|
|
908
|
+
} catch {
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const content = readFileSync2(configPath);
|
|
912
|
+
const backupPath = await writeBackup(dir, content);
|
|
913
|
+
await pruneOldBackups(dir);
|
|
914
|
+
return backupPath;
|
|
915
|
+
}
|
|
561
916
|
async function installSkill(opts = {}) {
|
|
562
917
|
const home = opts.homeOverride ?? homedir();
|
|
563
|
-
const skillPath =
|
|
918
|
+
const skillPath = join3(home, ".claude", "skills", "tandem", "SKILL.md");
|
|
919
|
+
assertPathSafe(skillPath, { allowedRoots: opts.homeOverride ? [opts.homeOverride] : void 0 });
|
|
564
920
|
await mkdir(dirname2(skillPath), { recursive: true });
|
|
565
921
|
await atomicWrite(SKILL_CONTENT, skillPath);
|
|
566
922
|
}
|
|
923
|
+
function readSkillVersion(skillContent) {
|
|
924
|
+
const match = skillContent.match(/^version:\s*(\d+)\s*$/m);
|
|
925
|
+
if (!match) return 0;
|
|
926
|
+
const n = parseInt(match[1], 10);
|
|
927
|
+
return Number.isFinite(n) ? n : 0;
|
|
928
|
+
}
|
|
567
929
|
function validateChannelShimPrereq(channelPath) {
|
|
568
930
|
return existsSync(channelPath);
|
|
569
931
|
}
|
|
@@ -578,7 +940,10 @@ async function applyConfigWithToken(token, opts = {}) {
|
|
|
578
940
|
targetKind: t.kind
|
|
579
941
|
});
|
|
580
942
|
try {
|
|
581
|
-
await applyConfig(
|
|
943
|
+
await applyConfig(
|
|
944
|
+
t.configPath,
|
|
945
|
+
applyOpsForCli(entries, { withChannelShim: !!opts.withChannelShim })
|
|
946
|
+
);
|
|
582
947
|
updated++;
|
|
583
948
|
} catch (err) {
|
|
584
949
|
errors.push(`${t.label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -586,6 +951,53 @@ async function applyConfigWithToken(token, opts = {}) {
|
|
|
586
951
|
}
|
|
587
952
|
return { updated, errors };
|
|
588
953
|
}
|
|
954
|
+
var __dirname2, PACKAGE_ROOT, CHANNEL_DIST, MCP_URL, MAX_CONFIG_BYTES, PathRejectedError, DEFAULT_ROOTS_CACHE, MSIX_PACKAGE_PATTERN, BUNDLED_SKILL_VERSION;
|
|
955
|
+
var init_apply = __esm({
|
|
956
|
+
"src/server/integrations/apply.ts"() {
|
|
957
|
+
"use strict";
|
|
958
|
+
init_skill_content();
|
|
959
|
+
init_constants();
|
|
960
|
+
init_platform();
|
|
961
|
+
init_acl_win();
|
|
962
|
+
init_backup();
|
|
963
|
+
__dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
964
|
+
PACKAGE_ROOT = (() => {
|
|
965
|
+
const fromBundle = resolve2(__dirname2, "../..");
|
|
966
|
+
if (existsSync(join3(fromBundle, "package.json"))) return fromBundle;
|
|
967
|
+
return resolve2(__dirname2, "../../..");
|
|
968
|
+
})();
|
|
969
|
+
CHANNEL_DIST = resolve2(PACKAGE_ROOT, "dist/channel/index.js");
|
|
970
|
+
MCP_URL = `http://127.0.0.1:${DEFAULT_MCP_PORT}`;
|
|
971
|
+
MAX_CONFIG_BYTES = 5 * 1024 * 1024;
|
|
972
|
+
PathRejectedError = class extends Error {
|
|
973
|
+
constructor(path6, reason, message) {
|
|
974
|
+
super(message);
|
|
975
|
+
this.path = path6;
|
|
976
|
+
this.reason = reason;
|
|
977
|
+
}
|
|
978
|
+
name = "PathRejectedError";
|
|
979
|
+
};
|
|
980
|
+
DEFAULT_ROOTS_CACHE = /* @__PURE__ */ new Map();
|
|
981
|
+
MSIX_PACKAGE_PATTERN = /^Claude_[A-Za-z0-9]+$/;
|
|
982
|
+
BUNDLED_SKILL_VERSION = readSkillVersion(SKILL_CONTENT);
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
// src/cli/setup.ts
|
|
987
|
+
var setup_exports = {};
|
|
988
|
+
__export(setup_exports, {
|
|
989
|
+
PathRejectedError: () => PathRejectedError,
|
|
990
|
+
applyConfig: () => applyConfig,
|
|
991
|
+
applyConfigWithToken: () => applyConfigWithToken,
|
|
992
|
+
applyOpsForCli: () => applyOpsForCli,
|
|
993
|
+
buildMcpEntries: () => buildMcpEntries,
|
|
994
|
+
detectTargets: () => detectTargets,
|
|
995
|
+
installSkill: () => installSkill,
|
|
996
|
+
runSetup: () => runSetup,
|
|
997
|
+
validateChannelShimPrereq: () => validateChannelShimPrereq
|
|
998
|
+
});
|
|
999
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1000
|
+
import { join as join4 } from "path";
|
|
589
1001
|
async function runSetup(opts = {}) {
|
|
590
1002
|
console.error("\nTandem Setup\n");
|
|
591
1003
|
if (opts.withChannelShim && !validateChannelShimPrereq(CHANNEL_DIST)) {
|
|
@@ -614,7 +1026,10 @@ Run 'npm run build' first, or drop --with-channel-shim to use the plugin monitor
|
|
|
614
1026
|
targetKind: t.kind
|
|
615
1027
|
});
|
|
616
1028
|
try {
|
|
617
|
-
await applyConfig(
|
|
1029
|
+
await applyConfig(
|
|
1030
|
+
t.configPath,
|
|
1031
|
+
applyOpsForCli(entries, { withChannelShim: !!opts.withChannelShim })
|
|
1032
|
+
);
|
|
618
1033
|
console.error(` \x1B[32m\u2713\x1B[0m ${t.label}`);
|
|
619
1034
|
} catch (err) {
|
|
620
1035
|
failures++;
|
|
@@ -645,8 +1060,8 @@ Setup partially complete (${failures} target(s) failed). Start Tandem with: tand
|
|
|
645
1060
|
);
|
|
646
1061
|
}
|
|
647
1062
|
if (failures < targets.length) {
|
|
648
|
-
const pluginManifest =
|
|
649
|
-
const devInstructions =
|
|
1063
|
+
const pluginManifest = join4(PACKAGE_ROOT, ".claude-plugin", "plugin.json");
|
|
1064
|
+
const devInstructions = existsSync2(pluginManifest) ? ` Or for development, load directly from this package:
|
|
650
1065
|
|
|
651
1066
|
claude --plugin-dir ${PACKAGE_ROOT}
|
|
652
1067
|
|
|
@@ -658,16 +1073,11 @@ Setup partially complete (${failures} target(s) failed). Start Tandem with: tand
|
|
|
658
1073
|
);
|
|
659
1074
|
}
|
|
660
1075
|
}
|
|
661
|
-
var __dirname2, PACKAGE_ROOT, CHANNEL_DIST, MCP_URL;
|
|
662
1076
|
var init_setup = __esm({
|
|
663
1077
|
"src/cli/setup.ts"() {
|
|
664
1078
|
"use strict";
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
__dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
668
|
-
PACKAGE_ROOT = resolve2(__dirname2, "../..");
|
|
669
|
-
CHANNEL_DIST = resolve2(PACKAGE_ROOT, "dist/channel/index.js");
|
|
670
|
-
MCP_URL = `http://localhost:${DEFAULT_MCP_PORT}`;
|
|
1079
|
+
init_apply();
|
|
1080
|
+
init_apply();
|
|
671
1081
|
}
|
|
672
1082
|
});
|
|
673
1083
|
|
|
@@ -689,7 +1099,7 @@ function resolveTandemUrlCandidate(override) {
|
|
|
689
1099
|
for (const url of candidates) {
|
|
690
1100
|
if (url !== void 0 && url.trim() !== "") return url.trim();
|
|
691
1101
|
}
|
|
692
|
-
return `http://
|
|
1102
|
+
return `http://127.0.0.1:${DEFAULT_MCP_PORT}`;
|
|
693
1103
|
}
|
|
694
1104
|
function resolveAuthTokenCandidate(override) {
|
|
695
1105
|
const candidates = [
|
|
@@ -1057,6 +1467,21 @@ ${err.stack ?? ""}
|
|
|
1057
1467
|
}
|
|
1058
1468
|
});
|
|
1059
1469
|
|
|
1470
|
+
// src/shared/api-paths.ts
|
|
1471
|
+
var API_EVENTS, API_CHANNEL_AWARENESS, API_CHANNEL_ERROR, API_CHANNEL_REPLY, API_CHANNEL_PERMISSION, API_MODE, API_ROTATE_TOKEN;
|
|
1472
|
+
var init_api_paths = __esm({
|
|
1473
|
+
"src/shared/api-paths.ts"() {
|
|
1474
|
+
"use strict";
|
|
1475
|
+
API_EVENTS = "/api/events";
|
|
1476
|
+
API_CHANNEL_AWARENESS = "/api/channel-awareness";
|
|
1477
|
+
API_CHANNEL_ERROR = "/api/channel-error";
|
|
1478
|
+
API_CHANNEL_REPLY = "/api/channel-reply";
|
|
1479
|
+
API_CHANNEL_PERMISSION = "/api/channel-permission";
|
|
1480
|
+
API_MODE = "/api/mode";
|
|
1481
|
+
API_ROTATE_TOKEN = "/api/rotate-token";
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1060
1485
|
// src/shared/fetch-with-timeout.ts
|
|
1061
1486
|
async function fetchWithTimeout(url, init, timeoutMs) {
|
|
1062
1487
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
@@ -1199,50 +1624,113 @@ var init_types = __esm({
|
|
|
1199
1624
|
}
|
|
1200
1625
|
});
|
|
1201
1626
|
|
|
1202
|
-
// src/
|
|
1203
|
-
|
|
1627
|
+
// src/shared/positions/types.ts
|
|
1628
|
+
var init_types2 = __esm({
|
|
1629
|
+
"src/shared/positions/types.ts"() {
|
|
1630
|
+
"use strict";
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
// src/shared/types.ts
|
|
1635
|
+
import { z } from "zod";
|
|
1636
|
+
var AnnotationTypeSchema, AnnotationStatusSchema, HighlightColorSchema, SeveritySchema, TandemModeSchema, AuthorSchema, ReplyAuthorSchema, AnnotationActionSchema, ExportFormatSchema, DocumentFormatSchema, ToolErrorCodeSchema, ChannelErrorCodeSchema, CHANNEL_CONNECT_FAILED;
|
|
1637
|
+
var init_types3 = __esm({
|
|
1638
|
+
"src/shared/types.ts"() {
|
|
1639
|
+
"use strict";
|
|
1640
|
+
init_types2();
|
|
1641
|
+
AnnotationTypeSchema = z.enum(["highlight", "note", "comment"]);
|
|
1642
|
+
AnnotationStatusSchema = z.enum(["pending", "accepted", "dismissed"]);
|
|
1643
|
+
HighlightColorSchema = z.enum(["yellow", "green", "blue", "pink"]);
|
|
1644
|
+
SeveritySchema = z.enum(["info", "warning", "error", "success"]);
|
|
1645
|
+
TandemModeSchema = z.enum(["solo", "tandem"]);
|
|
1646
|
+
AuthorSchema = z.enum(["user", "claude", "import"]);
|
|
1647
|
+
ReplyAuthorSchema = z.enum(["user", "claude"]);
|
|
1648
|
+
AnnotationActionSchema = z.enum(["accept", "dismiss"]);
|
|
1649
|
+
ExportFormatSchema = z.enum(["markdown", "json"]);
|
|
1650
|
+
DocumentFormatSchema = z.enum(["md", "txt", "html", "docx"]);
|
|
1651
|
+
ToolErrorCodeSchema = z.enum([
|
|
1652
|
+
"RANGE_GONE",
|
|
1653
|
+
"RANGE_MOVED",
|
|
1654
|
+
"FILE_LOCKED",
|
|
1655
|
+
"FILE_NOT_FOUND",
|
|
1656
|
+
"NO_DOCUMENT",
|
|
1657
|
+
"INVALID_RANGE",
|
|
1658
|
+
"INVALID_ARGUMENT",
|
|
1659
|
+
"NOT_FOUND",
|
|
1660
|
+
"ANNOTATION_RESOLVED",
|
|
1661
|
+
"FORMAT_ERROR",
|
|
1662
|
+
"PERMISSION_DENIED"
|
|
1663
|
+
]);
|
|
1664
|
+
ChannelErrorCodeSchema = z.enum(["CHANNEL_CONNECT_FAILED", "MONITOR_CONNECT_FAILED"]);
|
|
1665
|
+
CHANNEL_CONNECT_FAILED = "CHANNEL_CONNECT_FAILED";
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
// src/shared/sse-consumer.ts
|
|
1670
|
+
function trackAwareness(p) {
|
|
1671
|
+
outstandingAwareness.add(p);
|
|
1672
|
+
p.finally(() => outstandingAwareness.delete(p));
|
|
1673
|
+
}
|
|
1674
|
+
async function runEventConsumer(opts) {
|
|
1675
|
+
await getCachedMode(opts.tandemUrl, opts.logPrefix).catch(() => {
|
|
1676
|
+
});
|
|
1204
1677
|
let retries = 0;
|
|
1205
1678
|
let lastEventId;
|
|
1206
1679
|
while (retries < CHANNEL_MAX_RETRIES) {
|
|
1207
1680
|
try {
|
|
1208
|
-
await
|
|
1209
|
-
|
|
1210
|
-
|
|
1681
|
+
await connectAndStreamOnce(opts, lastEventId, {
|
|
1682
|
+
onEventId: (id) => {
|
|
1683
|
+
lastEventId = id;
|
|
1684
|
+
},
|
|
1685
|
+
onStable: () => {
|
|
1686
|
+
retries = 0;
|
|
1687
|
+
}
|
|
1211
1688
|
});
|
|
1212
1689
|
} catch (err) {
|
|
1213
1690
|
retries++;
|
|
1214
1691
|
console.error(
|
|
1215
|
-
|
|
1692
|
+
`${opts.logPrefix} SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,
|
|
1216
1693
|
err instanceof Error ? err.message : err
|
|
1217
1694
|
);
|
|
1218
1695
|
if (retries >= CHANNEL_MAX_RETRIES) {
|
|
1219
|
-
console.error(
|
|
1696
|
+
console.error(`${opts.logPrefix} SSE connection exhausted, reporting error and exiting`);
|
|
1220
1697
|
try {
|
|
1221
1698
|
await fetchWithTimeout(
|
|
1222
|
-
`${tandemUrl}
|
|
1699
|
+
`${opts.tandemUrl}${API_CHANNEL_ERROR}`,
|
|
1223
1700
|
{
|
|
1224
1701
|
method: "POST",
|
|
1225
1702
|
headers: { "Content-Type": "application/json" },
|
|
1226
1703
|
body: JSON.stringify({
|
|
1227
|
-
error:
|
|
1228
|
-
message:
|
|
1704
|
+
error: opts.errorCode,
|
|
1705
|
+
message: `${opts.logPrefix} lost connection after ${CHANNEL_MAX_RETRIES} retries.`
|
|
1229
1706
|
})
|
|
1230
1707
|
},
|
|
1231
1708
|
CHANNEL_ERROR_REPORT_TIMEOUT_MS
|
|
1232
1709
|
);
|
|
1233
1710
|
} catch (reportErr) {
|
|
1234
1711
|
console.error(
|
|
1235
|
-
|
|
1236
|
-
describeFetchError(reportErr,
|
|
1712
|
+
`${opts.logPrefix} Could not report failure to server:`,
|
|
1713
|
+
describeFetchError(reportErr, API_CHANNEL_ERROR, CHANNEL_ERROR_REPORT_TIMEOUT_MS)
|
|
1237
1714
|
);
|
|
1238
1715
|
}
|
|
1716
|
+
opts.onExhaustion?.();
|
|
1239
1717
|
process.exit(1);
|
|
1240
1718
|
}
|
|
1241
|
-
|
|
1719
|
+
const delay = Math.min(CHANNEL_RETRY_DELAY_MS * 2 ** (retries - 1), RETRY_MAX_DELAY_MS);
|
|
1720
|
+
console.error(
|
|
1721
|
+
`${opts.logPrefix} Retrying in ${delay}ms (attempt ${retries}/${CHANNEL_MAX_RETRIES})...`
|
|
1722
|
+
);
|
|
1723
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
1242
1724
|
}
|
|
1243
1725
|
}
|
|
1726
|
+
console.error(
|
|
1727
|
+
`${opts.logPrefix} Retry loop exited unexpectedly (retries=${retries}/${CHANNEL_MAX_RETRIES})`
|
|
1728
|
+
);
|
|
1729
|
+
process.exit(1);
|
|
1244
1730
|
}
|
|
1245
|
-
async function
|
|
1731
|
+
async function connectAndStreamOnce(opts, lastEventId, cb) {
|
|
1732
|
+
const onStable = cb.onStable ?? (() => {
|
|
1733
|
+
});
|
|
1246
1734
|
const headers = { Accept: "text/event-stream" };
|
|
1247
1735
|
if (lastEventId) headers["Last-Event-ID"] = lastEventId;
|
|
1248
1736
|
const connectCtrl = new AbortController();
|
|
@@ -1252,12 +1740,16 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1252
1740
|
);
|
|
1253
1741
|
let res;
|
|
1254
1742
|
try {
|
|
1255
|
-
res = await authFetch(`${tandemUrl}
|
|
1743
|
+
res = await authFetch(`${opts.tandemUrl}${API_EVENTS}`, {
|
|
1744
|
+
headers,
|
|
1745
|
+
signal: connectCtrl.signal
|
|
1746
|
+
});
|
|
1256
1747
|
} finally {
|
|
1257
1748
|
clearTimeout(connectTimer);
|
|
1258
1749
|
}
|
|
1259
1750
|
if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
|
|
1260
1751
|
if (!res.body) throw new Error("SSE endpoint returned no body");
|
|
1752
|
+
const stableTimer = setTimeout(onStable, STABLE_CONNECTION_MS);
|
|
1261
1753
|
const reader = res.body.getReader();
|
|
1262
1754
|
const decoder = new TextDecoder();
|
|
1263
1755
|
let buffer = "";
|
|
@@ -1270,13 +1762,10 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1270
1762
|
});
|
|
1271
1763
|
}
|
|
1272
1764
|
}, CHANNEL_SSE_INACTIVITY_TIMEOUT_MS / 4);
|
|
1273
|
-
let awarenessTimer = null;
|
|
1274
|
-
let clearAwarenessTimer = null;
|
|
1275
1765
|
let pendingAwareness = null;
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
`${tandemUrl}/api/channel-awareness`,
|
|
1766
|
+
function clearAwarenessNow(documentId) {
|
|
1767
|
+
const p = fetchWithTimeout(
|
|
1768
|
+
`${opts.tandemUrl}${API_CHANNEL_AWARENESS}`,
|
|
1280
1769
|
{
|
|
1281
1770
|
method: "POST",
|
|
1282
1771
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1289,17 +1778,23 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1289
1778
|
CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
|
|
1290
1779
|
).catch((err) => {
|
|
1291
1780
|
console.error(
|
|
1292
|
-
|
|
1293
|
-
describeFetchError(
|
|
1781
|
+
`${opts.logPrefix} Awareness clear failed:`,
|
|
1782
|
+
describeFetchError(
|
|
1783
|
+
err,
|
|
1784
|
+
`${API_CHANNEL_AWARENESS} clear`,
|
|
1785
|
+
CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
|
|
1786
|
+
)
|
|
1294
1787
|
);
|
|
1295
1788
|
});
|
|
1789
|
+
trackAwareness(p);
|
|
1296
1790
|
}
|
|
1297
1791
|
function flushAwareness() {
|
|
1298
1792
|
if (!pendingAwareness) return;
|
|
1299
1793
|
const event = pendingAwareness;
|
|
1300
1794
|
pendingAwareness = null;
|
|
1301
|
-
|
|
1302
|
-
|
|
1795
|
+
if (event.documentId) shutdownTimers.lastDocumentId = event.documentId;
|
|
1796
|
+
const p = fetchWithTimeout(
|
|
1797
|
+
`${opts.tandemUrl}${API_CHANNEL_AWARENESS}`,
|
|
1303
1798
|
{
|
|
1304
1799
|
method: "POST",
|
|
1305
1800
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1312,21 +1807,25 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1312
1807
|
CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
|
|
1313
1808
|
).catch((err) => {
|
|
1314
1809
|
console.error(
|
|
1315
|
-
|
|
1810
|
+
`${opts.logPrefix} Awareness update failed:`,
|
|
1316
1811
|
describeFetchError(
|
|
1317
1812
|
err,
|
|
1318
|
-
|
|
1813
|
+
`${API_CHANNEL_AWARENESS} update`,
|
|
1319
1814
|
CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
|
|
1320
1815
|
)
|
|
1321
1816
|
);
|
|
1322
1817
|
});
|
|
1323
|
-
|
|
1324
|
-
|
|
1818
|
+
trackAwareness(p);
|
|
1819
|
+
if (shutdownTimers.clearAwarenessTimer) clearTimeout(shutdownTimers.clearAwarenessTimer);
|
|
1820
|
+
shutdownTimers.clearAwarenessTimer = setTimeout(
|
|
1821
|
+
() => clearAwarenessNow(event.documentId),
|
|
1822
|
+
AWARENESS_CLEAR_MS
|
|
1823
|
+
);
|
|
1325
1824
|
}
|
|
1326
1825
|
function scheduleAwareness(event) {
|
|
1327
1826
|
pendingAwareness = event;
|
|
1328
|
-
if (awarenessTimer) clearTimeout(awarenessTimer);
|
|
1329
|
-
awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
|
|
1827
|
+
if (shutdownTimers.awarenessTimer) clearTimeout(shutdownTimers.awarenessTimer);
|
|
1828
|
+
shutdownTimers.awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
|
|
1330
1829
|
}
|
|
1331
1830
|
try {
|
|
1332
1831
|
while (true) {
|
|
@@ -1354,90 +1853,164 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1354
1853
|
else if (line.startsWith("data: ")) data = line.slice(6);
|
|
1355
1854
|
}
|
|
1356
1855
|
if (!data) continue;
|
|
1357
|
-
let
|
|
1856
|
+
let raw;
|
|
1358
1857
|
try {
|
|
1359
|
-
|
|
1360
|
-
} catch {
|
|
1858
|
+
raw = JSON.parse(data);
|
|
1859
|
+
} catch (err) {
|
|
1361
1860
|
console.error(
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
data.slice(0, 200)
|
|
1861
|
+
`${opts.logPrefix} SSE JSON parse failed (eventId=${eventId ?? "none"}, len=${data.length}): ${err instanceof Error ? err.message : err}. Tail:`,
|
|
1862
|
+
data.slice(Math.max(0, data.length - 200))
|
|
1365
1863
|
);
|
|
1366
|
-
if (eventId) onEventId(eventId);
|
|
1864
|
+
if (eventId) cb.onEventId(eventId);
|
|
1367
1865
|
continue;
|
|
1368
1866
|
}
|
|
1867
|
+
const event = parseTandemEvent(raw);
|
|
1369
1868
|
if (!event) {
|
|
1370
1869
|
console.error(
|
|
1371
|
-
|
|
1372
|
-
eventId,
|
|
1373
|
-
data.slice(0, 200)
|
|
1870
|
+
`${opts.logPrefix} SSE event failed validation (eventId=${eventId ?? "none"}): shape mismatch`
|
|
1374
1871
|
);
|
|
1375
|
-
if (eventId) onEventId(eventId);
|
|
1872
|
+
if (eventId) cb.onEventId(eventId);
|
|
1376
1873
|
continue;
|
|
1377
1874
|
}
|
|
1378
1875
|
if (event.type !== "chat:message") {
|
|
1379
|
-
|
|
1380
|
-
if (
|
|
1381
|
-
console.error(
|
|
1382
|
-
if (eventId) onEventId(eventId);
|
|
1876
|
+
refreshMode(opts.tandemUrl, opts.logPrefix);
|
|
1877
|
+
if (getModeSync() === "solo") {
|
|
1878
|
+
console.error(`${opts.logPrefix} Solo mode: suppressed ${event.type} event`);
|
|
1879
|
+
if (eventId) cb.onEventId(eventId);
|
|
1383
1880
|
continue;
|
|
1384
1881
|
}
|
|
1385
1882
|
}
|
|
1386
1883
|
try {
|
|
1387
|
-
await
|
|
1388
|
-
method: "notifications/claude/channel",
|
|
1389
|
-
params: {
|
|
1390
|
-
content: formatEventContent(event),
|
|
1391
|
-
meta: formatEventMeta(event)
|
|
1392
|
-
}
|
|
1393
|
-
});
|
|
1884
|
+
await opts.onEvent(event, eventId);
|
|
1394
1885
|
} catch (err) {
|
|
1395
|
-
console.error(
|
|
1886
|
+
console.error(`${opts.logPrefix} onEvent failed (transport broken?):`, err);
|
|
1396
1887
|
throw err;
|
|
1397
1888
|
}
|
|
1398
|
-
if (eventId) onEventId(eventId);
|
|
1889
|
+
if (eventId) cb.onEventId(eventId);
|
|
1399
1890
|
scheduleAwareness(event);
|
|
1400
1891
|
}
|
|
1401
1892
|
}
|
|
1402
1893
|
} finally {
|
|
1894
|
+
clearTimeout(stableTimer);
|
|
1403
1895
|
clearInterval(watchdog);
|
|
1404
|
-
if (awarenessTimer) clearTimeout(awarenessTimer);
|
|
1405
|
-
if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
|
|
1896
|
+
if (shutdownTimers.awarenessTimer) clearTimeout(shutdownTimers.awarenessTimer);
|
|
1897
|
+
if (shutdownTimers.clearAwarenessTimer) clearTimeout(shutdownTimers.clearAwarenessTimer);
|
|
1898
|
+
shutdownTimers.awarenessTimer = null;
|
|
1899
|
+
shutdownTimers.clearAwarenessTimer = null;
|
|
1900
|
+
pendingAwareness = null;
|
|
1406
1901
|
}
|
|
1407
1902
|
}
|
|
1408
|
-
async function
|
|
1409
|
-
const now = Date.now();
|
|
1410
|
-
if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
|
|
1903
|
+
async function fetchMode(tandemUrl) {
|
|
1411
1904
|
try {
|
|
1412
|
-
const res = await fetchWithTimeout(
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1905
|
+
const res = await fetchWithTimeout(
|
|
1906
|
+
`${tandemUrl}${API_MODE}`,
|
|
1907
|
+
{},
|
|
1908
|
+
CHANNEL_MODE_FETCH_TIMEOUT_MS
|
|
1909
|
+
);
|
|
1910
|
+
if (!res.ok) return { ok: false, reason: `status ${res.status}` };
|
|
1911
|
+
const body = await res.json();
|
|
1912
|
+
const parsed = TandemModeSchema.safeParse(body.mode);
|
|
1913
|
+
if (!parsed.success) return { ok: false, reason: `invalid mode ${JSON.stringify(body.mode)}` };
|
|
1914
|
+
return { ok: true, mode: parsed.data };
|
|
1420
1915
|
} catch (err) {
|
|
1916
|
+
return { ok: false, reason: describeFetchError(err, API_MODE, CHANNEL_MODE_FETCH_TIMEOUT_MS) };
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
async function getCachedMode(tandemUrl, logPrefix = "[Tandem]") {
|
|
1920
|
+
const now = Date.now();
|
|
1921
|
+
if (now - cachedModeAt < MODE_CACHE_TTL_MS && cachedModeAt !== 0) return cachedMode;
|
|
1922
|
+
const result = await fetchMode(tandemUrl);
|
|
1923
|
+
if (!result.ok) {
|
|
1924
|
+
if (cachedModeAt !== 0) {
|
|
1925
|
+
console.error(
|
|
1926
|
+
`${logPrefix} Mode check failed (${result.reason}), preserving last known mode '${cachedMode}'`
|
|
1927
|
+
);
|
|
1928
|
+
return cachedMode;
|
|
1929
|
+
}
|
|
1421
1930
|
console.error(
|
|
1422
|
-
|
|
1423
|
-
describeFetchError(err, "/api/mode", CHANNEL_MODE_FETCH_TIMEOUT_MS)
|
|
1931
|
+
`${logPrefix} Mode check failed (${result.reason}), no prior mode \u2014 using cold-start default '${TANDEM_MODE_DEFAULT}'`
|
|
1424
1932
|
);
|
|
1425
|
-
|
|
1933
|
+
cachedMode = TANDEM_MODE_DEFAULT;
|
|
1934
|
+
return TANDEM_MODE_DEFAULT;
|
|
1426
1935
|
}
|
|
1936
|
+
cachedMode = result.mode;
|
|
1937
|
+
cachedModeAt = now;
|
|
1427
1938
|
return cachedMode;
|
|
1428
1939
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1940
|
+
function getModeSync() {
|
|
1941
|
+
return cachedMode;
|
|
1942
|
+
}
|
|
1943
|
+
function refreshMode(tandemUrl, logPrefix) {
|
|
1944
|
+
if (_modeRefreshInFlight) return;
|
|
1945
|
+
const now = Date.now();
|
|
1946
|
+
if (now - cachedModeAt < MODE_CACHE_TTL_MS) return;
|
|
1947
|
+
if (now - cachedModeFailedAt < MODE_CACHE_TTL_MS) return;
|
|
1948
|
+
_modeRefreshInFlight = (async () => {
|
|
1949
|
+
try {
|
|
1950
|
+
const result = await fetchMode(tandemUrl);
|
|
1951
|
+
if (result.ok) {
|
|
1952
|
+
cachedMode = result.mode;
|
|
1953
|
+
cachedModeAt = Date.now();
|
|
1954
|
+
cachedModeFailedAt = 0;
|
|
1955
|
+
} else {
|
|
1956
|
+
cachedModeFailedAt = Date.now();
|
|
1957
|
+
console.error(
|
|
1958
|
+
`${logPrefix} Background mode refresh failed (${result.reason}), keeping cached`
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
} finally {
|
|
1962
|
+
_modeRefreshInFlight = null;
|
|
1963
|
+
}
|
|
1964
|
+
})().catch((err) => {
|
|
1965
|
+
console.error(`${logPrefix} refreshMode unexpected error:`, err);
|
|
1966
|
+
cachedModeFailedAt = Date.now();
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
var AWARENESS_DEBOUNCE_MS, AWARENESS_CLEAR_MS, MODE_CACHE_TTL_MS, STABLE_CONNECTION_MS, RETRY_MAX_DELAY_MS, shutdownTimers, outstandingAwareness, cachedMode, cachedModeAt, cachedModeFailedAt, _modeRefreshInFlight;
|
|
1970
|
+
var init_sse_consumer = __esm({
|
|
1971
|
+
"src/shared/sse-consumer.ts"() {
|
|
1432
1972
|
"use strict";
|
|
1973
|
+
init_api_paths();
|
|
1433
1974
|
init_cli_runtime();
|
|
1434
1975
|
init_constants();
|
|
1435
1976
|
init_types();
|
|
1436
1977
|
init_fetch_with_timeout();
|
|
1978
|
+
init_types3();
|
|
1437
1979
|
AWARENESS_DEBOUNCE_MS = 500;
|
|
1980
|
+
AWARENESS_CLEAR_MS = 3e3;
|
|
1438
1981
|
MODE_CACHE_TTL_MS = 2e3;
|
|
1439
|
-
|
|
1982
|
+
STABLE_CONNECTION_MS = 6e4;
|
|
1983
|
+
RETRY_MAX_DELAY_MS = 3e4;
|
|
1984
|
+
shutdownTimers = { awarenessTimer: null, clearAwarenessTimer: null, lastDocumentId: null };
|
|
1985
|
+
outstandingAwareness = /* @__PURE__ */ new Set();
|
|
1986
|
+
cachedMode = TANDEM_MODE_DEFAULT;
|
|
1440
1987
|
cachedModeAt = 0;
|
|
1988
|
+
cachedModeFailedAt = 0;
|
|
1989
|
+
_modeRefreshInFlight = null;
|
|
1990
|
+
}
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1993
|
+
// src/channel/event-bridge.ts
|
|
1994
|
+
async function startEventBridge(mcp, tandemUrl) {
|
|
1995
|
+
return runEventConsumer({
|
|
1996
|
+
tandemUrl,
|
|
1997
|
+
logPrefix: "[Channel]",
|
|
1998
|
+
errorCode: CHANNEL_CONNECT_FAILED,
|
|
1999
|
+
onEvent: (event) => mcp.notification({
|
|
2000
|
+
method: "notifications/claude/channel",
|
|
2001
|
+
params: {
|
|
2002
|
+
content: formatEventContent(event),
|
|
2003
|
+
meta: formatEventMeta(event)
|
|
2004
|
+
}
|
|
2005
|
+
})
|
|
2006
|
+
});
|
|
2007
|
+
}
|
|
2008
|
+
var init_event_bridge = __esm({
|
|
2009
|
+
"src/channel/event-bridge.ts"() {
|
|
2010
|
+
"use strict";
|
|
2011
|
+
init_types();
|
|
2012
|
+
init_sse_consumer();
|
|
2013
|
+
init_types3();
|
|
1441
2014
|
}
|
|
1442
2015
|
});
|
|
1443
2016
|
|
|
@@ -1446,7 +2019,7 @@ import { createConnection } from "net";
|
|
|
1446
2019
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
1447
2020
|
import { StdioServerTransport as StdioServerTransport2 } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1448
2021
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
1449
|
-
import { z } from "zod";
|
|
2022
|
+
import { z as z2 } from "zod";
|
|
1450
2023
|
async function runChannel(opts = {}) {
|
|
1451
2024
|
redirectConsoleToStderr();
|
|
1452
2025
|
const tandemUrl = resolveTandemUrl();
|
|
@@ -1501,7 +2074,7 @@ async function runChannel(opts = {}) {
|
|
|
1501
2074
|
const args2 = req.params.arguments;
|
|
1502
2075
|
try {
|
|
1503
2076
|
const res = await fetchWithTimeout(
|
|
1504
|
-
`${tandemUrl}
|
|
2077
|
+
`${tandemUrl}${API_CHANNEL_REPLY}`,
|
|
1505
2078
|
{
|
|
1506
2079
|
method: "POST",
|
|
1507
2080
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1535,7 +2108,7 @@ async function runChannel(opts = {}) {
|
|
|
1535
2108
|
type: "text",
|
|
1536
2109
|
text: `Failed to send reply: ${describeFetchError(
|
|
1537
2110
|
err,
|
|
1538
|
-
|
|
2111
|
+
API_CHANNEL_REPLY,
|
|
1539
2112
|
CHANNEL_REPLY_FETCH_TIMEOUT_MS
|
|
1540
2113
|
)}`
|
|
1541
2114
|
}
|
|
@@ -1546,19 +2119,19 @@ async function runChannel(opts = {}) {
|
|
|
1546
2119
|
}
|
|
1547
2120
|
throw new Error(`Unknown tool: ${req.params.name}`);
|
|
1548
2121
|
});
|
|
1549
|
-
const PermissionRequestSchema =
|
|
1550
|
-
method:
|
|
1551
|
-
params:
|
|
1552
|
-
request_id:
|
|
1553
|
-
tool_name:
|
|
1554
|
-
description:
|
|
1555
|
-
input_preview:
|
|
2122
|
+
const PermissionRequestSchema = z2.object({
|
|
2123
|
+
method: z2.literal("notifications/claude/channel/permission_request"),
|
|
2124
|
+
params: z2.object({
|
|
2125
|
+
request_id: z2.string(),
|
|
2126
|
+
tool_name: z2.string(),
|
|
2127
|
+
description: z2.string(),
|
|
2128
|
+
input_preview: z2.string()
|
|
1556
2129
|
})
|
|
1557
2130
|
});
|
|
1558
2131
|
mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
|
|
1559
2132
|
try {
|
|
1560
2133
|
const res = await fetchWithTimeout(
|
|
1561
|
-
`${tandemUrl}
|
|
2134
|
+
`${tandemUrl}${API_CHANNEL_PERMISSION}`,
|
|
1562
2135
|
{
|
|
1563
2136
|
method: "POST",
|
|
1564
2137
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1579,7 +2152,7 @@ async function runChannel(opts = {}) {
|
|
|
1579
2152
|
} catch (err) {
|
|
1580
2153
|
console.error(
|
|
1581
2154
|
"[Channel] Failed to forward permission request:",
|
|
1582
|
-
describeFetchError(err,
|
|
2155
|
+
describeFetchError(err, API_CHANNEL_PERMISSION, CHANNEL_PERMISSION_FETCH_TIMEOUT_MS)
|
|
1583
2156
|
);
|
|
1584
2157
|
}
|
|
1585
2158
|
});
|
|
@@ -1605,7 +2178,7 @@ async function checkServerReachable(url, timeoutMs = 2e3) {
|
|
|
1605
2178
|
parsed = new URL(url);
|
|
1606
2179
|
} catch {
|
|
1607
2180
|
console.error(
|
|
1608
|
-
`[Channel] Invalid TANDEM_URL: "${url}" \u2014 expected format: http://
|
|
2181
|
+
`[Channel] Invalid TANDEM_URL: "${url}" \u2014 expected format: http://127.0.0.1:3479`
|
|
1609
2182
|
);
|
|
1610
2183
|
return false;
|
|
1611
2184
|
}
|
|
@@ -1630,6 +2203,7 @@ async function checkServerReachable(url, timeoutMs = 2e3) {
|
|
|
1630
2203
|
var init_run = __esm({
|
|
1631
2204
|
"src/channel/run.ts"() {
|
|
1632
2205
|
"use strict";
|
|
2206
|
+
init_api_paths();
|
|
1633
2207
|
init_cli_runtime();
|
|
1634
2208
|
init_constants();
|
|
1635
2209
|
init_fetch_with_timeout();
|
|
@@ -1655,11 +2229,11 @@ var init_channel = __esm({
|
|
|
1655
2229
|
});
|
|
1656
2230
|
|
|
1657
2231
|
// src/shared/auth/token-file.ts
|
|
1658
|
-
import
|
|
2232
|
+
import envPaths2 from "env-paths";
|
|
1659
2233
|
import fs2 from "fs";
|
|
1660
|
-
import
|
|
2234
|
+
import path4 from "path";
|
|
1661
2235
|
function getTokenFilePath() {
|
|
1662
|
-
return
|
|
2236
|
+
return path4.join(envPaths2("tandem", { suffix: "" }).data, TOKEN_FILE_NAME);
|
|
1663
2237
|
}
|
|
1664
2238
|
async function readTokenFromFile() {
|
|
1665
2239
|
const filePath = getTokenFilePath();
|
|
@@ -1696,7 +2270,7 @@ __export(rotate_token_exports, {
|
|
|
1696
2270
|
});
|
|
1697
2271
|
import { createHash, randomBytes } from "crypto";
|
|
1698
2272
|
import { promises as fsPromises2 } from "fs";
|
|
1699
|
-
import
|
|
2273
|
+
import path5 from "path";
|
|
1700
2274
|
function fingerprint(token) {
|
|
1701
2275
|
return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 8);
|
|
1702
2276
|
}
|
|
@@ -1724,8 +2298,8 @@ async function rotateToken() {
|
|
|
1724
2298
|
}
|
|
1725
2299
|
const newToken = generateToken();
|
|
1726
2300
|
const tokenPath = getTokenFilePath();
|
|
1727
|
-
const dir =
|
|
1728
|
-
const tmpPath =
|
|
2301
|
+
const dir = path5.dirname(tokenPath);
|
|
2302
|
+
const tmpPath = path5.join(dir, `.auth-token-tmp-${randomBytes(4).toString("hex")}`);
|
|
1729
2303
|
try {
|
|
1730
2304
|
await fsPromises2.writeFile(tmpPath, newToken, { encoding: "utf8", mode: 384 });
|
|
1731
2305
|
await fsPromises2.rename(tmpPath, tokenPath);
|
|
@@ -1739,7 +2313,7 @@ async function rotateToken() {
|
|
|
1739
2313
|
let serverRejected = false;
|
|
1740
2314
|
let serverRejectedStatus = 0;
|
|
1741
2315
|
try {
|
|
1742
|
-
const resp = await fetch(`${serverUrl}
|
|
2316
|
+
const resp = await fetch(`${serverUrl}${API_ROTATE_TOKEN}`, {
|
|
1743
2317
|
method: "POST",
|
|
1744
2318
|
headers: {
|
|
1745
2319
|
"Content-Type": "application/json",
|
|
@@ -1809,6 +2383,7 @@ async function rotateToken() {
|
|
|
1809
2383
|
var init_rotate_token = __esm({
|
|
1810
2384
|
"src/cli/rotate-token.ts"() {
|
|
1811
2385
|
"use strict";
|
|
2386
|
+
init_api_paths();
|
|
1812
2387
|
init_token_file();
|
|
1813
2388
|
init_cli_runtime();
|
|
1814
2389
|
init_setup();
|
|
@@ -1821,19 +2396,23 @@ __export(start_exports, {
|
|
|
1821
2396
|
runStart: () => runStart
|
|
1822
2397
|
});
|
|
1823
2398
|
import { spawn } from "child_process";
|
|
1824
|
-
import { existsSync as
|
|
2399
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1825
2400
|
import { dirname as dirname3, resolve as resolve3 } from "path";
|
|
1826
2401
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
1827
2402
|
function runStart() {
|
|
1828
|
-
if (!
|
|
2403
|
+
if (!existsSync3(SERVER_DIST)) {
|
|
1829
2404
|
console.error(`[Tandem] Server not found at ${SERVER_DIST}`);
|
|
1830
2405
|
console.error("[Tandem] The installation may be corrupted. Try: npm install -g tandem-editor");
|
|
1831
2406
|
process.exit(1);
|
|
1832
2407
|
}
|
|
2408
|
+
console.error(
|
|
2409
|
+
"[Tandem] Browser distribution is deprecated; the Tauri desktop app is the primary form factor."
|
|
2410
|
+
);
|
|
2411
|
+
console.error("[Tandem] See https://github.com/bloknayrb/tandem/issues/477 for context.");
|
|
1833
2412
|
console.error("[Tandem] Starting server...");
|
|
1834
2413
|
const proc = spawn("node", [SERVER_DIST], {
|
|
1835
2414
|
stdio: "inherit",
|
|
1836
|
-
env:
|
|
2415
|
+
env: process.env
|
|
1837
2416
|
});
|
|
1838
2417
|
proc.on("error", (err) => {
|
|
1839
2418
|
console.error(`[Tandem] Failed to start server: ${err.message}`);
|
|
@@ -1872,7 +2451,7 @@ process.once("unhandledRejection", (reason) => {
|
|
|
1872
2451
|
`);
|
|
1873
2452
|
process.exit(1);
|
|
1874
2453
|
});
|
|
1875
|
-
var version = true ? "0.
|
|
2454
|
+
var version = true ? "0.13.0" : "0.0.0-dev";
|
|
1876
2455
|
var args = process.argv.slice(2);
|
|
1877
2456
|
var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
|
|
1878
2457
|
if (!isStdioMode) {
|
|
@@ -1883,7 +2462,7 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
1883
2462
|
|
|
1884
2463
|
Usage:
|
|
1885
2464
|
tandem Start Tandem server and open the editor
|
|
1886
|
-
tandem setup Register MCP tools with Claude Code / Claude Desktop
|
|
2465
|
+
tandem setup Register MCP tools with your AI client (Claude Code / Claude Desktop by default)
|
|
1887
2466
|
tandem setup --force Register to default paths regardless of detection
|
|
1888
2467
|
tandem setup --with-channel-shim Also register the stdio channel shim (legacy opt-in)
|
|
1889
2468
|
tandem rotate-token Rotate the auth token with a 60-second grace window
|