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.
Files changed (40) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +3 -3
  3. package/CHANGELOG.md +201 -72
  4. package/README.md +141 -238
  5. package/dist/channel/index.js +211 -81
  6. package/dist/channel/index.js.map +1 -1
  7. package/dist/cli/index.js +749 -170
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/client/assets/CoworkSettings-BOYbyKul.js +3 -0
  10. package/dist/client/assets/event-CNdo2oXa.js +1 -0
  11. package/dist/client/assets/index-D8uS4cj7.css +1 -0
  12. package/dist/client/assets/index-Dm_QtxGQ.js +1 -0
  13. package/dist/client/assets/index-g-KwmRn9.js +271 -0
  14. package/dist/client/assets/webview-KiZyy_pC.js +1 -0
  15. package/dist/client/assets/window-DePn7tLG.js +1 -0
  16. package/dist/client/fonts/OFL-Hanuman.txt +93 -0
  17. package/dist/client/fonts/OFL-InterTight.txt +93 -0
  18. package/dist/client/fonts/OFL-JetBrainsMono.txt +93 -0
  19. package/dist/client/fonts/OFL-SNPro.txt +93 -0
  20. package/dist/client/fonts/OFL-Sono.txt +93 -0
  21. package/dist/client/fonts/OFL-SourceSerif4.txt +93 -0
  22. package/dist/client/fonts/hanuman-latin.woff2 +0 -0
  23. package/dist/client/fonts/jetbrains-mono-latin.woff2 +0 -0
  24. package/dist/client/fonts/sn-pro-latin.woff2 +0 -0
  25. package/dist/client/fonts/sono-latin.woff2 +0 -0
  26. package/dist/client/fonts/source-serif-4-latin.woff2 +0 -0
  27. package/dist/client/index.html +206 -17
  28. package/dist/client/logo.png +0 -0
  29. package/dist/monitor/index.js +241 -160
  30. package/dist/monitor/index.js.map +1 -1
  31. package/dist/server/index.js +22828 -19659
  32. package/dist/server/index.js.map +1 -1
  33. package/package.json +12 -4
  34. package/sample/welcome.md +6 -6
  35. package/skills/tandem/SKILL.md +15 -0
  36. package/dist/client/assets/CoworkSettings-DK3jjdwK.js +0 -3
  37. package/dist/client/assets/index-CfT503n4.js +0 -297
  38. package/dist/client/assets/index-DeJe09pn.css +0 -1
  39. package/dist/client/assets/webview-Ben21ZLJ.js +0 -1
  40. 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, MAX_WS_PAYLOAD, IDLE_TIMEOUT, 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";
@@ -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/cli/skill-content.ts
385
- import { readFileSync } from "fs";
386
- import { dirname, resolve } from "path";
387
- import { fileURLToPath } from "url";
388
- var __dirname, SKILL_PATH, SKILL_CONTENT;
389
- var init_skill_content = __esm({
390
- "src/cli/skill-content.ts"() {
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
- __dirname = dirname(fileURLToPath(import.meta.url));
393
- SKILL_PATH = resolve(__dirname, "../../skills/tandem/SKILL.md");
394
- SKILL_CONTENT = readFileSync(SKILL_PATH, "utf-8");
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/cli/setup.ts
399
- var setup_exports = {};
400
- __export(setup_exports, {
401
- applyConfig: () => applyConfig,
402
- applyConfigWithToken: () => applyConfigWithToken,
403
- buildMcpEntries: () => buildMcpEntries,
404
- detectTargets: () => detectTargets,
405
- installSkill: () => installSkill,
406
- runSetup: () => runSetup,
407
- validateChannelShimPrereq: () => validateChannelShimPrereq
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 { existsSync, readdirSync, readFileSync as readFileSync2 } from "fs";
411
- import { copyFile, mkdir, rename, unlink, writeFile } from "fs/promises";
412
- import { homedir } from "os";
413
- import { basename, dirname as dirname2, join, resolve as resolve2 } from "path";
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 = join(home, ".claude.json");
452
- const claudeCodeDir = join(home, ".claude");
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 ?? join(home, "AppData", "Roaming");
459
- desktopConfig = join(appdata, "Claude", "claude_desktop_config.json");
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 = join(
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 = join(home, ".config", "claude", "claude_desktop_config.json");
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 ?? join(home, "AppData", "Local");
476
- const packagesDir = join(localAppData, "Packages");
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
- for (const pkg of entries.filter((n) => n.startsWith("Claude_"))) {
480
- const msixConfig = join(
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 = entries.filter((n) => n.startsWith("Claude_")).length > 1 ? ` (${pkg.slice(0, 12)}\u2026)` : "";
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 = join(dirname2(dest), `.tandem-setup-${randomUUID()}.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 unlink(tmp).catch((cleanupErr) => {
511
- console.error(` Warning: could not remove temp file ${tmp}: ${cleanupErr.message}`);
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 unlink(tmp).catch((cleanupErr) => {
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 applyConfig(configPath, entries) {
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
- existing = JSON.parse(readFileSync2(configPath, "utf-8"));
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 backupPath = `${configPath}.broken-${Date.now()}`;
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
- await copyFile(configPath, backupPath);
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 ${basename(backupPath)}, replacing with fresh config`
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
- ...entries
886
+ ...ops.create
548
887
  };
549
- if (!entries["tandem-channel"]) {
550
- if (merged["tandem-channel"]) {
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["tandem-channel"];
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 = join(home, ".claude", "skills", "tandem", "SKILL.md");
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(t.configPath, entries);
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(t.configPath, entries);
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 = join(PACKAGE_ROOT, ".claude-plugin", "plugin.json");
649
- const devInstructions = existsSync(pluginManifest) ? ` Or for development, load directly from this package:
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
- init_constants();
666
- init_skill_content();
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://localhost:${DEFAULT_MCP_PORT}`;
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/channel/event-bridge.ts
1203
- async function startEventBridge(mcp, tandemUrl) {
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 connectAndStream(mcp, tandemUrl, lastEventId, (id) => {
1209
- lastEventId = id;
1210
- retries = 0;
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
- `[Channel] SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,
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("[Channel] SSE connection exhausted, reporting error and exiting");
1696
+ console.error(`${opts.logPrefix} SSE connection exhausted, reporting error and exiting`);
1220
1697
  try {
1221
1698
  await fetchWithTimeout(
1222
- `${tandemUrl}/api/channel-error`,
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: "CHANNEL_CONNECT_FAILED",
1228
- message: `Channel shim lost connection after ${CHANNEL_MAX_RETRIES} retries.`
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
- "[Channel] Could not report failure to server:",
1236
- describeFetchError(reportErr, "/api/channel-error", CHANNEL_ERROR_REPORT_TIMEOUT_MS)
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
- await new Promise((r) => setTimeout(r, CHANNEL_RETRY_DELAY_MS));
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 connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
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}/api/events`, { headers, signal: connectCtrl.signal });
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
- const AWARENESS_CLEAR_MS = 3e3;
1277
- function clearAwareness(documentId) {
1278
- fetchWithTimeout(
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
- "[Channel] clearAwareness failed (non-fatal):",
1293
- describeFetchError(err, "/api/channel-awareness clear", CHANNEL_AWARENESS_FETCH_TIMEOUT_MS)
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
- fetchWithTimeout(
1302
- `${tandemUrl}/api/channel-awareness`,
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
- "[Channel] Awareness update failed:",
1810
+ `${opts.logPrefix} Awareness update failed:`,
1316
1811
  describeFetchError(
1317
1812
  err,
1318
- "/api/channel-awareness update",
1813
+ `${API_CHANNEL_AWARENESS} update`,
1319
1814
  CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
1320
1815
  )
1321
1816
  );
1322
1817
  });
1323
- if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
1324
- clearAwarenessTimer = setTimeout(() => clearAwareness(event.documentId), AWARENESS_CLEAR_MS);
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 event;
1856
+ let raw;
1358
1857
  try {
1359
- event = parseTandemEvent(JSON.parse(data));
1360
- } catch {
1858
+ raw = JSON.parse(data);
1859
+ } catch (err) {
1361
1860
  console.error(
1362
- "[Channel] Malformed SSE event data (skipping), eventId=%s:",
1363
- eventId,
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
- "[Channel] Invalid SSE event structure (skipping), eventId=%s:",
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
- const mode = await getCachedMode(tandemUrl);
1380
- if (mode === "solo") {
1381
- console.error(`[Channel] Solo mode: suppressed ${event.type} event`);
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 mcp.notification({
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("[Channel] MCP notification failed (transport broken?):", err);
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 getCachedMode(tandemUrl) {
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(`${tandemUrl}/api/mode`, {}, CHANNEL_MODE_FETCH_TIMEOUT_MS);
1413
- if (res.ok) {
1414
- const { mode } = await res.json();
1415
- cachedMode = mode;
1416
- } else {
1417
- console.error(`[Channel] Mode check returned ${res.status}, using cached: "${cachedMode}"`);
1418
- }
1419
- cachedModeAt = now;
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
- "[Channel] Mode check failed, delivering event (fail-open):",
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
- cachedModeAt = now;
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
- var AWARENESS_DEBOUNCE_MS, MODE_CACHE_TTL_MS, cachedMode, cachedModeAt;
1430
- var init_event_bridge = __esm({
1431
- "src/channel/event-bridge.ts"() {
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
- cachedMode = "tandem";
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}/api/channel-reply`,
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
- "/api/channel-reply",
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 = z.object({
1550
- method: z.literal("notifications/claude/channel/permission_request"),
1551
- params: z.object({
1552
- request_id: z.string(),
1553
- tool_name: z.string(),
1554
- description: z.string(),
1555
- input_preview: z.string()
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}/api/channel-permission`,
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, "/api/channel-permission", CHANNEL_PERMISSION_FETCH_TIMEOUT_MS)
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://localhost:3479`
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 envPaths from "env-paths";
2232
+ import envPaths2 from "env-paths";
1659
2233
  import fs2 from "fs";
1660
- import path3 from "path";
2234
+ import path4 from "path";
1661
2235
  function getTokenFilePath() {
1662
- return path3.join(envPaths("tandem", { suffix: "" }).data, TOKEN_FILE_NAME);
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 path4 from "path";
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 = path4.dirname(tokenPath);
1728
- const tmpPath = path4.join(dir, `.auth-token-tmp-${randomBytes(4).toString("hex")}`);
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}/api/rotate-token`, {
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 existsSync2 } from "fs";
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 (!existsSync2(SERVER_DIST)) {
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: { ...process.env, TANDEM_OPEN_BROWSER: "1" }
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.11.2" : "0.0.0-dev";
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