tandem-editor 0.12.0 → 0.13.5
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 +1 -1
- package/CHANGELOG.md +121 -9
- package/README.md +142 -237
- package/dist/channel/index.js +158 -69
- package/dist/channel/index.js.map +1 -1
- package/dist/cli/index.js +656 -148
- package/dist/cli/index.js.map +1 -1
- package/dist/client/assets/CoworkSettings-BEvEpByR.js +3 -0
- package/dist/client/assets/CoworkSettings-C-1BnhBH.css +1 -0
- package/dist/client/assets/index-CXkPU0mo.css +1 -0
- package/dist/client/assets/index-D6kdw28e.js +238 -0
- package/dist/client/assets/index-Dm_QtxGQ.js +1 -0
- package/dist/client/assets/webview-KiZyy_pC.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 +341 -17
- package/dist/client/logo.png +0 -0
- package/dist/monitor/index.js +235 -161
- package/dist/monitor/index.js.map +1 -1
- package/dist/server/index.js +25323 -21754
- package/dist/server/index.js.map +1 -1
- package/package.json +11 -3
- package/sample/welcome.md +6 -6
- package/skills/tandem/SKILL.md +15 -0
- package/dist/client/assets/CoworkSettings-C0cS9R7L.js +0 -3
- package/dist/client/assets/index-Dn5JwXA3.css +0 -1
- package/dist/client/assets/index-n-vFW5By.js +0 -299
- package/dist/client/assets/webview-Bhf-n_os.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, SESSION_MAX_AGE, 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_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";
|
|
@@ -365,6 +379,7 @@ var init_constants = __esm({
|
|
|
365
379
|
TANDEM_ISSUES_NEW_URL = `${TANDEM_REPO_URL}/issues/new`;
|
|
366
380
|
MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
367
381
|
SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
|
|
382
|
+
TANDEM_MODE_DEFAULT = "tandem";
|
|
368
383
|
CHANNEL_MAX_RETRIES = 5;
|
|
369
384
|
CHANNEL_RETRY_DELAY_MS = 2e3;
|
|
370
385
|
CHANNEL_CONNECT_FETCH_TIMEOUT_MS = 1e4;
|
|
@@ -379,37 +394,236 @@ var init_constants = __esm({
|
|
|
379
394
|
}
|
|
380
395
|
});
|
|
381
396
|
|
|
382
|
-
// src/
|
|
383
|
-
import
|
|
384
|
-
import
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
"
|
|
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"() {
|
|
389
408
|
"use strict";
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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");
|
|
393
412
|
}
|
|
394
413
|
});
|
|
395
414
|
|
|
396
|
-
// src/
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
+
}
|
|
406
493
|
});
|
|
494
|
+
|
|
495
|
+
// src/server/integrations/backup.ts
|
|
407
496
|
import { randomUUID } from "crypto";
|
|
408
|
-
import {
|
|
409
|
-
import {
|
|
410
|
-
|
|
411
|
-
|
|
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";
|
|
412
620
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
621
|
+
function applyOpsForCli(create, opts) {
|
|
622
|
+
return {
|
|
623
|
+
create,
|
|
624
|
+
remove: opts.withChannelShim ? [] : ["tandem-channel"]
|
|
625
|
+
};
|
|
626
|
+
}
|
|
413
627
|
function buildMcpEntries(channelPath, opts = {}) {
|
|
414
628
|
const isDesktop = opts.targetKind === "claude-desktop";
|
|
415
629
|
let tandemEntry;
|
|
@@ -443,20 +657,66 @@ function buildMcpEntries(channelPath, opts = {}) {
|
|
|
443
657
|
}
|
|
444
658
|
return entries;
|
|
445
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
|
+
}
|
|
446
706
|
function detectTargets(opts = {}) {
|
|
447
707
|
const home = opts.homeOverride ?? homedir();
|
|
448
708
|
const targets = [];
|
|
449
|
-
const claudeCodeConfig =
|
|
450
|
-
const claudeCodeDir =
|
|
709
|
+
const claudeCodeConfig = join3(home, ".claude.json");
|
|
710
|
+
const claudeCodeDir = join3(home, ".claude");
|
|
451
711
|
if (opts.force || existsSync(claudeCodeConfig) || existsSync(claudeCodeDir)) {
|
|
452
712
|
targets.push({ label: "Claude Code", configPath: claudeCodeConfig, kind: "claude-code" });
|
|
453
713
|
}
|
|
454
714
|
let desktopConfig = null;
|
|
455
715
|
if (process.platform === "win32") {
|
|
456
|
-
const appdata = process.env.APPDATA ??
|
|
457
|
-
desktopConfig =
|
|
716
|
+
const appdata = process.env.APPDATA ?? join3(home, "AppData", "Roaming");
|
|
717
|
+
desktopConfig = join3(appdata, "Claude", "claude_desktop_config.json");
|
|
458
718
|
} else if (process.platform === "darwin") {
|
|
459
|
-
desktopConfig =
|
|
719
|
+
desktopConfig = join3(
|
|
460
720
|
home,
|
|
461
721
|
"Library",
|
|
462
722
|
"Application Support",
|
|
@@ -464,18 +724,24 @@ function detectTargets(opts = {}) {
|
|
|
464
724
|
"claude_desktop_config.json"
|
|
465
725
|
);
|
|
466
726
|
} else {
|
|
467
|
-
desktopConfig =
|
|
727
|
+
desktopConfig = join3(home, ".config", "claude", "claude_desktop_config.json");
|
|
468
728
|
}
|
|
469
729
|
if (desktopConfig && (opts.force || existsSync(desktopConfig))) {
|
|
470
730
|
targets.push({ label: "Claude Desktop", configPath: desktopConfig, kind: "claude-desktop" });
|
|
471
731
|
}
|
|
472
732
|
if (process.platform === "win32") {
|
|
473
|
-
const localAppData = opts.localAppDataOverride ?? process.env.LOCALAPPDATA ??
|
|
474
|
-
|
|
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");
|
|
475
740
|
try {
|
|
476
741
|
const entries = readdirSync(packagesDir);
|
|
477
|
-
|
|
478
|
-
|
|
742
|
+
const matching = entries.filter((n) => MSIX_PACKAGE_PATTERN.test(n));
|
|
743
|
+
for (const pkg of matching) {
|
|
744
|
+
const msixConfig = join3(
|
|
479
745
|
packagesDir,
|
|
480
746
|
pkg,
|
|
481
747
|
"LocalCache",
|
|
@@ -484,7 +750,7 @@ function detectTargets(opts = {}) {
|
|
|
484
750
|
"claude_desktop_config.json"
|
|
485
751
|
);
|
|
486
752
|
if (opts.force || existsSync(msixConfig)) {
|
|
487
|
-
const suffix =
|
|
753
|
+
const suffix = matching.length > 1 ? ` (${pkg.slice(0, 12)}\u2026)` : "";
|
|
488
754
|
targets.push({
|
|
489
755
|
label: `Claude Desktop MSIX${suffix}`,
|
|
490
756
|
configPath: msixConfig,
|
|
@@ -498,37 +764,102 @@ function detectTargets(opts = {}) {
|
|
|
498
764
|
return targets;
|
|
499
765
|
}
|
|
500
766
|
async function atomicWrite(content, dest) {
|
|
501
|
-
const tmp =
|
|
767
|
+
const tmp = join3(dirname2(dest), `.tandem-setup-${randomUUID2()}.tmp`);
|
|
502
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
|
+
}
|
|
503
779
|
try {
|
|
504
780
|
await rename(tmp, dest);
|
|
505
781
|
} catch (err) {
|
|
506
782
|
if (err.code === "EXDEV") {
|
|
507
783
|
await copyFile(tmp, dest);
|
|
508
|
-
await
|
|
509
|
-
|
|
510
|
-
|
|
784
|
+
await unlinkOrLeak(tmp, err);
|
|
785
|
+
if (process.platform === "win32") await setRestrictiveAcl(dest);
|
|
786
|
+
else await chmod(dest, 384);
|
|
511
787
|
} else {
|
|
512
|
-
await
|
|
513
|
-
console.error(` Warning: could not remove temp file ${tmp}: ${cleanupErr.message}`);
|
|
514
|
-
});
|
|
788
|
+
await unlinkOrLeak(tmp, err);
|
|
515
789
|
throw err;
|
|
516
790
|
}
|
|
517
791
|
}
|
|
518
792
|
}
|
|
519
|
-
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
|
+
}
|
|
520
817
|
let existing = {};
|
|
521
818
|
try {
|
|
522
|
-
|
|
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;
|
|
523
830
|
} catch (err) {
|
|
524
831
|
const code = err.code;
|
|
525
832
|
if (code === "ENOENT") {
|
|
526
833
|
} else if (err instanceof SyntaxError) {
|
|
527
|
-
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
|
+
);
|
|
528
841
|
try {
|
|
529
|
-
|
|
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
|
+
}
|
|
530
861
|
console.error(
|
|
531
|
-
` Warning: ${configPath} contains malformed JSON \u2014 backed up to ${
|
|
862
|
+
` Warning: ${configPath} contains malformed JSON \u2014 backed up to ${backupPath2}, replacing with fresh config`
|
|
532
863
|
);
|
|
533
864
|
} catch (copyErr) {
|
|
534
865
|
console.error(
|
|
@@ -540,28 +871,61 @@ async function applyConfig(configPath, entries) {
|
|
|
540
871
|
throw err;
|
|
541
872
|
}
|
|
542
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
|
+
}
|
|
543
884
|
const merged = {
|
|
544
885
|
...existing.mcpServers ?? {},
|
|
545
|
-
...
|
|
886
|
+
...ops.create
|
|
546
887
|
};
|
|
547
|
-
|
|
548
|
-
if (merged[
|
|
549
|
-
console.error(
|
|
550
|
-
` Warning: removed stale tandem-channel entry from ${configPath} (legacy Tauri install artifact)`
|
|
551
|
-
);
|
|
888
|
+
for (const key of ops.remove) {
|
|
889
|
+
if (merged[key]) {
|
|
890
|
+
console.error(` Note: removed mcpServers.${key} from ${configPath}`);
|
|
552
891
|
}
|
|
553
|
-
delete merged[
|
|
892
|
+
delete merged[key];
|
|
554
893
|
}
|
|
555
894
|
const updated = { ...existing, mcpServers: merged };
|
|
556
895
|
await mkdir(dirname2(configPath), { recursive: true });
|
|
557
896
|
await atomicWrite(JSON.stringify(updated, null, 2) + "\n", configPath);
|
|
558
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
|
+
}
|
|
559
916
|
async function installSkill(opts = {}) {
|
|
560
917
|
const home = opts.homeOverride ?? homedir();
|
|
561
|
-
const skillPath =
|
|
918
|
+
const skillPath = join3(home, ".claude", "skills", "tandem", "SKILL.md");
|
|
919
|
+
assertPathSafe(skillPath, { allowedRoots: opts.homeOverride ? [opts.homeOverride] : void 0 });
|
|
562
920
|
await mkdir(dirname2(skillPath), { recursive: true });
|
|
563
921
|
await atomicWrite(SKILL_CONTENT, skillPath);
|
|
564
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
|
+
}
|
|
565
929
|
function validateChannelShimPrereq(channelPath) {
|
|
566
930
|
return existsSync(channelPath);
|
|
567
931
|
}
|
|
@@ -576,7 +940,10 @@ async function applyConfigWithToken(token, opts = {}) {
|
|
|
576
940
|
targetKind: t.kind
|
|
577
941
|
});
|
|
578
942
|
try {
|
|
579
|
-
await applyConfig(
|
|
943
|
+
await applyConfig(
|
|
944
|
+
t.configPath,
|
|
945
|
+
applyOpsForCli(entries, { withChannelShim: !!opts.withChannelShim })
|
|
946
|
+
);
|
|
580
947
|
updated++;
|
|
581
948
|
} catch (err) {
|
|
582
949
|
errors.push(`${t.label}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -584,6 +951,53 @@ async function applyConfigWithToken(token, opts = {}) {
|
|
|
584
951
|
}
|
|
585
952
|
return { updated, errors };
|
|
586
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";
|
|
587
1001
|
async function runSetup(opts = {}) {
|
|
588
1002
|
console.error("\nTandem Setup\n");
|
|
589
1003
|
if (opts.withChannelShim && !validateChannelShimPrereq(CHANNEL_DIST)) {
|
|
@@ -612,7 +1026,10 @@ Run 'npm run build' first, or drop --with-channel-shim to use the plugin monitor
|
|
|
612
1026
|
targetKind: t.kind
|
|
613
1027
|
});
|
|
614
1028
|
try {
|
|
615
|
-
await applyConfig(
|
|
1029
|
+
await applyConfig(
|
|
1030
|
+
t.configPath,
|
|
1031
|
+
applyOpsForCli(entries, { withChannelShim: !!opts.withChannelShim })
|
|
1032
|
+
);
|
|
616
1033
|
console.error(` \x1B[32m\u2713\x1B[0m ${t.label}`);
|
|
617
1034
|
} catch (err) {
|
|
618
1035
|
failures++;
|
|
@@ -643,8 +1060,8 @@ Setup partially complete (${failures} target(s) failed). Start Tandem with: tand
|
|
|
643
1060
|
);
|
|
644
1061
|
}
|
|
645
1062
|
if (failures < targets.length) {
|
|
646
|
-
const pluginManifest =
|
|
647
|
-
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:
|
|
648
1065
|
|
|
649
1066
|
claude --plugin-dir ${PACKAGE_ROOT}
|
|
650
1067
|
|
|
@@ -656,16 +1073,11 @@ Setup partially complete (${failures} target(s) failed). Start Tandem with: tand
|
|
|
656
1073
|
);
|
|
657
1074
|
}
|
|
658
1075
|
}
|
|
659
|
-
var __dirname2, PACKAGE_ROOT, CHANNEL_DIST, MCP_URL;
|
|
660
1076
|
var init_setup = __esm({
|
|
661
1077
|
"src/cli/setup.ts"() {
|
|
662
1078
|
"use strict";
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
__dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
666
|
-
PACKAGE_ROOT = resolve2(__dirname2, "../..");
|
|
667
|
-
CHANNEL_DIST = resolve2(PACKAGE_ROOT, "dist/channel/index.js");
|
|
668
|
-
MCP_URL = `http://127.0.0.1:${DEFAULT_MCP_PORT}`;
|
|
1079
|
+
init_apply();
|
|
1080
|
+
init_apply();
|
|
669
1081
|
}
|
|
670
1082
|
});
|
|
671
1083
|
|
|
@@ -1254,50 +1666,71 @@ var init_types3 = __esm({
|
|
|
1254
1666
|
}
|
|
1255
1667
|
});
|
|
1256
1668
|
|
|
1257
|
-
// src/
|
|
1258
|
-
|
|
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
|
+
});
|
|
1259
1677
|
let retries = 0;
|
|
1260
1678
|
let lastEventId;
|
|
1261
1679
|
while (retries < CHANNEL_MAX_RETRIES) {
|
|
1262
1680
|
try {
|
|
1263
|
-
await
|
|
1264
|
-
|
|
1265
|
-
|
|
1681
|
+
await connectAndStreamOnce(opts, lastEventId, {
|
|
1682
|
+
onEventId: (id) => {
|
|
1683
|
+
lastEventId = id;
|
|
1684
|
+
},
|
|
1685
|
+
onStable: () => {
|
|
1686
|
+
retries = 0;
|
|
1687
|
+
}
|
|
1266
1688
|
});
|
|
1267
1689
|
} catch (err) {
|
|
1268
1690
|
retries++;
|
|
1269
1691
|
console.error(
|
|
1270
|
-
|
|
1692
|
+
`${opts.logPrefix} SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,
|
|
1271
1693
|
err instanceof Error ? err.message : err
|
|
1272
1694
|
);
|
|
1273
1695
|
if (retries >= CHANNEL_MAX_RETRIES) {
|
|
1274
|
-
console.error(
|
|
1696
|
+
console.error(`${opts.logPrefix} SSE connection exhausted, reporting error and exiting`);
|
|
1275
1697
|
try {
|
|
1276
1698
|
await fetchWithTimeout(
|
|
1277
|
-
`${tandemUrl}${API_CHANNEL_ERROR}`,
|
|
1699
|
+
`${opts.tandemUrl}${API_CHANNEL_ERROR}`,
|
|
1278
1700
|
{
|
|
1279
1701
|
method: "POST",
|
|
1280
1702
|
headers: { "Content-Type": "application/json" },
|
|
1281
1703
|
body: JSON.stringify({
|
|
1282
|
-
error:
|
|
1283
|
-
message:
|
|
1704
|
+
error: opts.errorCode,
|
|
1705
|
+
message: `${opts.logPrefix} lost connection after ${CHANNEL_MAX_RETRIES} retries.`
|
|
1284
1706
|
})
|
|
1285
1707
|
},
|
|
1286
1708
|
CHANNEL_ERROR_REPORT_TIMEOUT_MS
|
|
1287
1709
|
);
|
|
1288
1710
|
} catch (reportErr) {
|
|
1289
1711
|
console.error(
|
|
1290
|
-
|
|
1712
|
+
`${opts.logPrefix} Could not report failure to server:`,
|
|
1291
1713
|
describeFetchError(reportErr, API_CHANNEL_ERROR, CHANNEL_ERROR_REPORT_TIMEOUT_MS)
|
|
1292
1714
|
);
|
|
1293
1715
|
}
|
|
1716
|
+
opts.onExhaustion?.();
|
|
1294
1717
|
process.exit(1);
|
|
1295
1718
|
}
|
|
1296
|
-
|
|
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));
|
|
1297
1724
|
}
|
|
1298
1725
|
}
|
|
1726
|
+
console.error(
|
|
1727
|
+
`${opts.logPrefix} Retry loop exited unexpectedly (retries=${retries}/${CHANNEL_MAX_RETRIES})`
|
|
1728
|
+
);
|
|
1729
|
+
process.exit(1);
|
|
1299
1730
|
}
|
|
1300
|
-
async function
|
|
1731
|
+
async function connectAndStreamOnce(opts, lastEventId, cb) {
|
|
1732
|
+
const onStable = cb.onStable ?? (() => {
|
|
1733
|
+
});
|
|
1301
1734
|
const headers = { Accept: "text/event-stream" };
|
|
1302
1735
|
if (lastEventId) headers["Last-Event-ID"] = lastEventId;
|
|
1303
1736
|
const connectCtrl = new AbortController();
|
|
@@ -1307,12 +1740,16 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1307
1740
|
);
|
|
1308
1741
|
let res;
|
|
1309
1742
|
try {
|
|
1310
|
-
res = await authFetch(`${tandemUrl}${API_EVENTS}`, {
|
|
1743
|
+
res = await authFetch(`${opts.tandemUrl}${API_EVENTS}`, {
|
|
1744
|
+
headers,
|
|
1745
|
+
signal: connectCtrl.signal
|
|
1746
|
+
});
|
|
1311
1747
|
} finally {
|
|
1312
1748
|
clearTimeout(connectTimer);
|
|
1313
1749
|
}
|
|
1314
1750
|
if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
|
|
1315
1751
|
if (!res.body) throw new Error("SSE endpoint returned no body");
|
|
1752
|
+
const stableTimer = setTimeout(onStable, STABLE_CONNECTION_MS);
|
|
1316
1753
|
const reader = res.body.getReader();
|
|
1317
1754
|
const decoder = new TextDecoder();
|
|
1318
1755
|
let buffer = "";
|
|
@@ -1325,13 +1762,10 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1325
1762
|
});
|
|
1326
1763
|
}
|
|
1327
1764
|
}, CHANNEL_SSE_INACTIVITY_TIMEOUT_MS / 4);
|
|
1328
|
-
let awarenessTimer = null;
|
|
1329
|
-
let clearAwarenessTimer = null;
|
|
1330
1765
|
let pendingAwareness = null;
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
`${tandemUrl}${API_CHANNEL_AWARENESS}`,
|
|
1766
|
+
function clearAwarenessNow(documentId) {
|
|
1767
|
+
const p = fetchWithTimeout(
|
|
1768
|
+
`${opts.tandemUrl}${API_CHANNEL_AWARENESS}`,
|
|
1335
1769
|
{
|
|
1336
1770
|
method: "POST",
|
|
1337
1771
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1344,7 +1778,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1344
1778
|
CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
|
|
1345
1779
|
).catch((err) => {
|
|
1346
1780
|
console.error(
|
|
1347
|
-
|
|
1781
|
+
`${opts.logPrefix} Awareness clear failed:`,
|
|
1348
1782
|
describeFetchError(
|
|
1349
1783
|
err,
|
|
1350
1784
|
`${API_CHANNEL_AWARENESS} clear`,
|
|
@@ -1352,13 +1786,15 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1352
1786
|
)
|
|
1353
1787
|
);
|
|
1354
1788
|
});
|
|
1789
|
+
trackAwareness(p);
|
|
1355
1790
|
}
|
|
1356
1791
|
function flushAwareness() {
|
|
1357
1792
|
if (!pendingAwareness) return;
|
|
1358
1793
|
const event = pendingAwareness;
|
|
1359
1794
|
pendingAwareness = null;
|
|
1360
|
-
|
|
1361
|
-
|
|
1795
|
+
if (event.documentId) shutdownTimers.lastDocumentId = event.documentId;
|
|
1796
|
+
const p = fetchWithTimeout(
|
|
1797
|
+
`${opts.tandemUrl}${API_CHANNEL_AWARENESS}`,
|
|
1362
1798
|
{
|
|
1363
1799
|
method: "POST",
|
|
1364
1800
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1371,7 +1807,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1371
1807
|
CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
|
|
1372
1808
|
).catch((err) => {
|
|
1373
1809
|
console.error(
|
|
1374
|
-
|
|
1810
|
+
`${opts.logPrefix} Awareness update failed:`,
|
|
1375
1811
|
describeFetchError(
|
|
1376
1812
|
err,
|
|
1377
1813
|
`${API_CHANNEL_AWARENESS} update`,
|
|
@@ -1379,13 +1815,17 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1379
1815
|
)
|
|
1380
1816
|
);
|
|
1381
1817
|
});
|
|
1382
|
-
|
|
1383
|
-
|
|
1818
|
+
trackAwareness(p);
|
|
1819
|
+
if (shutdownTimers.clearAwarenessTimer) clearTimeout(shutdownTimers.clearAwarenessTimer);
|
|
1820
|
+
shutdownTimers.clearAwarenessTimer = setTimeout(
|
|
1821
|
+
() => clearAwarenessNow(event.documentId),
|
|
1822
|
+
AWARENESS_CLEAR_MS
|
|
1823
|
+
);
|
|
1384
1824
|
}
|
|
1385
1825
|
function scheduleAwareness(event) {
|
|
1386
1826
|
pendingAwareness = event;
|
|
1387
|
-
if (awarenessTimer) clearTimeout(awarenessTimer);
|
|
1388
|
-
awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
|
|
1827
|
+
if (shutdownTimers.awarenessTimer) clearTimeout(shutdownTimers.awarenessTimer);
|
|
1828
|
+
shutdownTimers.awarenessTimer = setTimeout(flushAwareness, AWARENESS_DEBOUNCE_MS);
|
|
1389
1829
|
}
|
|
1390
1830
|
try {
|
|
1391
1831
|
while (true) {
|
|
@@ -1413,85 +1853,122 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
|
|
|
1413
1853
|
else if (line.startsWith("data: ")) data = line.slice(6);
|
|
1414
1854
|
}
|
|
1415
1855
|
if (!data) continue;
|
|
1416
|
-
let
|
|
1856
|
+
let raw;
|
|
1417
1857
|
try {
|
|
1418
|
-
|
|
1419
|
-
} catch {
|
|
1858
|
+
raw = JSON.parse(data);
|
|
1859
|
+
} catch (err) {
|
|
1420
1860
|
console.error(
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
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))
|
|
1424
1863
|
);
|
|
1425
|
-
if (eventId) onEventId(eventId);
|
|
1864
|
+
if (eventId) cb.onEventId(eventId);
|
|
1426
1865
|
continue;
|
|
1427
1866
|
}
|
|
1867
|
+
const event = parseTandemEvent(raw);
|
|
1428
1868
|
if (!event) {
|
|
1429
1869
|
console.error(
|
|
1430
|
-
|
|
1431
|
-
eventId,
|
|
1432
|
-
data.slice(0, 200)
|
|
1870
|
+
`${opts.logPrefix} SSE event failed validation (eventId=${eventId ?? "none"}): shape mismatch`
|
|
1433
1871
|
);
|
|
1434
|
-
if (eventId) onEventId(eventId);
|
|
1872
|
+
if (eventId) cb.onEventId(eventId);
|
|
1435
1873
|
continue;
|
|
1436
1874
|
}
|
|
1437
1875
|
if (event.type !== "chat:message") {
|
|
1438
|
-
|
|
1439
|
-
if (
|
|
1440
|
-
console.error(
|
|
1441
|
-
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);
|
|
1442
1880
|
continue;
|
|
1443
1881
|
}
|
|
1444
1882
|
}
|
|
1445
1883
|
try {
|
|
1446
|
-
await
|
|
1447
|
-
method: "notifications/claude/channel",
|
|
1448
|
-
params: {
|
|
1449
|
-
content: formatEventContent(event),
|
|
1450
|
-
meta: formatEventMeta(event)
|
|
1451
|
-
}
|
|
1452
|
-
});
|
|
1884
|
+
await opts.onEvent(event, eventId);
|
|
1453
1885
|
} catch (err) {
|
|
1454
|
-
console.error(
|
|
1886
|
+
console.error(`${opts.logPrefix} onEvent failed (transport broken?):`, err);
|
|
1455
1887
|
throw err;
|
|
1456
1888
|
}
|
|
1457
|
-
if (eventId) onEventId(eventId);
|
|
1889
|
+
if (eventId) cb.onEventId(eventId);
|
|
1458
1890
|
scheduleAwareness(event);
|
|
1459
1891
|
}
|
|
1460
1892
|
}
|
|
1461
1893
|
} finally {
|
|
1894
|
+
clearTimeout(stableTimer);
|
|
1462
1895
|
clearInterval(watchdog);
|
|
1463
|
-
if (awarenessTimer) clearTimeout(awarenessTimer);
|
|
1464
|
-
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;
|
|
1465
1901
|
}
|
|
1466
1902
|
}
|
|
1467
|
-
async function
|
|
1468
|
-
const now = Date.now();
|
|
1469
|
-
if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
|
|
1903
|
+
async function fetchMode(tandemUrl) {
|
|
1470
1904
|
try {
|
|
1471
1905
|
const res = await fetchWithTimeout(
|
|
1472
1906
|
`${tandemUrl}${API_MODE}`,
|
|
1473
1907
|
{},
|
|
1474
1908
|
CHANNEL_MODE_FETCH_TIMEOUT_MS
|
|
1475
1909
|
);
|
|
1476
|
-
if (res.ok) {
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
}
|
|
1482
|
-
cachedModeAt = now;
|
|
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 };
|
|
1483
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
|
+
}
|
|
1484
1930
|
console.error(
|
|
1485
|
-
|
|
1486
|
-
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}'`
|
|
1487
1932
|
);
|
|
1488
|
-
|
|
1933
|
+
cachedMode = TANDEM_MODE_DEFAULT;
|
|
1934
|
+
return TANDEM_MODE_DEFAULT;
|
|
1489
1935
|
}
|
|
1936
|
+
cachedMode = result.mode;
|
|
1937
|
+
cachedModeAt = now;
|
|
1490
1938
|
return cachedMode;
|
|
1491
1939
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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"() {
|
|
1495
1972
|
"use strict";
|
|
1496
1973
|
init_api_paths();
|
|
1497
1974
|
init_cli_runtime();
|
|
@@ -1500,9 +1977,40 @@ var init_event_bridge = __esm({
|
|
|
1500
1977
|
init_fetch_with_timeout();
|
|
1501
1978
|
init_types3();
|
|
1502
1979
|
AWARENESS_DEBOUNCE_MS = 500;
|
|
1980
|
+
AWARENESS_CLEAR_MS = 3e3;
|
|
1503
1981
|
MODE_CACHE_TTL_MS = 2e3;
|
|
1504
|
-
|
|
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;
|
|
1505
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();
|
|
1506
2014
|
}
|
|
1507
2015
|
});
|
|
1508
2016
|
|
|
@@ -1721,11 +2229,11 @@ var init_channel = __esm({
|
|
|
1721
2229
|
});
|
|
1722
2230
|
|
|
1723
2231
|
// src/shared/auth/token-file.ts
|
|
1724
|
-
import
|
|
2232
|
+
import envPaths2 from "env-paths";
|
|
1725
2233
|
import fs2 from "fs";
|
|
1726
|
-
import
|
|
2234
|
+
import path4 from "path";
|
|
1727
2235
|
function getTokenFilePath() {
|
|
1728
|
-
return
|
|
2236
|
+
return path4.join(envPaths2("tandem", { suffix: "" }).data, TOKEN_FILE_NAME);
|
|
1729
2237
|
}
|
|
1730
2238
|
async function readTokenFromFile() {
|
|
1731
2239
|
const filePath = getTokenFilePath();
|
|
@@ -1762,7 +2270,7 @@ __export(rotate_token_exports, {
|
|
|
1762
2270
|
});
|
|
1763
2271
|
import { createHash, randomBytes } from "crypto";
|
|
1764
2272
|
import { promises as fsPromises2 } from "fs";
|
|
1765
|
-
import
|
|
2273
|
+
import path5 from "path";
|
|
1766
2274
|
function fingerprint(token) {
|
|
1767
2275
|
return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 8);
|
|
1768
2276
|
}
|
|
@@ -1790,8 +2298,8 @@ async function rotateToken() {
|
|
|
1790
2298
|
}
|
|
1791
2299
|
const newToken = generateToken();
|
|
1792
2300
|
const tokenPath = getTokenFilePath();
|
|
1793
|
-
const dir =
|
|
1794
|
-
const tmpPath =
|
|
2301
|
+
const dir = path5.dirname(tokenPath);
|
|
2302
|
+
const tmpPath = path5.join(dir, `.auth-token-tmp-${randomBytes(4).toString("hex")}`);
|
|
1795
2303
|
try {
|
|
1796
2304
|
await fsPromises2.writeFile(tmpPath, newToken, { encoding: "utf8", mode: 384 });
|
|
1797
2305
|
await fsPromises2.rename(tmpPath, tokenPath);
|
|
@@ -1888,11 +2396,11 @@ __export(start_exports, {
|
|
|
1888
2396
|
runStart: () => runStart
|
|
1889
2397
|
});
|
|
1890
2398
|
import { spawn } from "child_process";
|
|
1891
|
-
import { existsSync as
|
|
2399
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1892
2400
|
import { dirname as dirname3, resolve as resolve3 } from "path";
|
|
1893
2401
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
1894
2402
|
function runStart() {
|
|
1895
|
-
if (!
|
|
2403
|
+
if (!existsSync3(SERVER_DIST)) {
|
|
1896
2404
|
console.error(`[Tandem] Server not found at ${SERVER_DIST}`);
|
|
1897
2405
|
console.error("[Tandem] The installation may be corrupted. Try: npm install -g tandem-editor");
|
|
1898
2406
|
process.exit(1);
|
|
@@ -1943,7 +2451,7 @@ process.once("unhandledRejection", (reason) => {
|
|
|
1943
2451
|
`);
|
|
1944
2452
|
process.exit(1);
|
|
1945
2453
|
});
|
|
1946
|
-
var version = true ? "0.
|
|
2454
|
+
var version = true ? "0.13.5" : "0.0.0-dev";
|
|
1947
2455
|
var args = process.argv.slice(2);
|
|
1948
2456
|
var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
|
|
1949
2457
|
if (!isStdioMode) {
|
|
@@ -1954,7 +2462,7 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
1954
2462
|
|
|
1955
2463
|
Usage:
|
|
1956
2464
|
tandem Start Tandem server and open the editor
|
|
1957
|
-
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)
|
|
1958
2466
|
tandem setup --force Register to default paths regardless of detection
|
|
1959
2467
|
tandem setup --with-channel-shim Also register the stdio channel shim (legacy opt-in)
|
|
1960
2468
|
tandem rotate-token Rotate the auth token with a 60-second grace window
|