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.
Files changed (38) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +121 -9
  4. package/README.md +142 -237
  5. package/dist/channel/index.js +158 -69
  6. package/dist/channel/index.js.map +1 -1
  7. package/dist/cli/index.js +656 -148
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/client/assets/CoworkSettings-BEvEpByR.js +3 -0
  10. package/dist/client/assets/CoworkSettings-C-1BnhBH.css +1 -0
  11. package/dist/client/assets/index-CXkPU0mo.css +1 -0
  12. package/dist/client/assets/index-D6kdw28e.js +238 -0
  13. package/dist/client/assets/index-Dm_QtxGQ.js +1 -0
  14. package/dist/client/assets/webview-KiZyy_pC.js +1 -0
  15. package/dist/client/fonts/OFL-Hanuman.txt +93 -0
  16. package/dist/client/fonts/OFL-InterTight.txt +93 -0
  17. package/dist/client/fonts/OFL-JetBrainsMono.txt +93 -0
  18. package/dist/client/fonts/OFL-SNPro.txt +93 -0
  19. package/dist/client/fonts/OFL-Sono.txt +93 -0
  20. package/dist/client/fonts/OFL-SourceSerif4.txt +93 -0
  21. package/dist/client/fonts/hanuman-latin.woff2 +0 -0
  22. package/dist/client/fonts/jetbrains-mono-latin.woff2 +0 -0
  23. package/dist/client/fonts/sn-pro-latin.woff2 +0 -0
  24. package/dist/client/fonts/sono-latin.woff2 +0 -0
  25. package/dist/client/fonts/source-serif-4-latin.woff2 +0 -0
  26. package/dist/client/index.html +341 -17
  27. package/dist/client/logo.png +0 -0
  28. package/dist/monitor/index.js +235 -161
  29. package/dist/monitor/index.js.map +1 -1
  30. package/dist/server/index.js +25323 -21754
  31. package/dist/server/index.js.map +1 -1
  32. package/package.json +11 -3
  33. package/sample/welcome.md +6 -6
  34. package/skills/tandem/SKILL.md +15 -0
  35. package/dist/client/assets/CoworkSettings-C0cS9R7L.js +0 -3
  36. package/dist/client/assets/index-Dn5JwXA3.css +0 -1
  37. package/dist/client/assets/index-n-vFW5By.js +0 -299
  38. 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/cli/skill-content.ts
383
- import { readFileSync } from "fs";
384
- import { dirname, resolve } from "path";
385
- import { fileURLToPath } from "url";
386
- var __dirname, SKILL_PATH, SKILL_CONTENT;
387
- var init_skill_content = __esm({
388
- "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"() {
389
408
  "use strict";
390
- __dirname = dirname(fileURLToPath(import.meta.url));
391
- SKILL_PATH = resolve(__dirname, "../../skills/tandem/SKILL.md");
392
- 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");
393
412
  }
394
413
  });
395
414
 
396
- // src/cli/setup.ts
397
- var setup_exports = {};
398
- __export(setup_exports, {
399
- applyConfig: () => applyConfig,
400
- applyConfigWithToken: () => applyConfigWithToken,
401
- buildMcpEntries: () => buildMcpEntries,
402
- detectTargets: () => detectTargets,
403
- installSkill: () => installSkill,
404
- runSetup: () => runSetup,
405
- 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
+ }
406
493
  });
494
+
495
+ // src/server/integrations/backup.ts
407
496
  import { randomUUID } from "crypto";
408
- import { existsSync, readdirSync, readFileSync as readFileSync2 } from "fs";
409
- import { copyFile, mkdir, rename, unlink, writeFile } from "fs/promises";
410
- import { homedir } from "os";
411
- 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";
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 = join(home, ".claude.json");
450
- const claudeCodeDir = join(home, ".claude");
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 ?? join(home, "AppData", "Roaming");
457
- 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");
458
718
  } else if (process.platform === "darwin") {
459
- desktopConfig = join(
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 = join(home, ".config", "claude", "claude_desktop_config.json");
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 ?? join(home, "AppData", "Local");
474
- 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");
475
740
  try {
476
741
  const entries = readdirSync(packagesDir);
477
- for (const pkg of entries.filter((n) => n.startsWith("Claude_"))) {
478
- const msixConfig = join(
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 = entries.filter((n) => n.startsWith("Claude_")).length > 1 ? ` (${pkg.slice(0, 12)}\u2026)` : "";
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 = join(dirname2(dest), `.tandem-setup-${randomUUID()}.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 unlink(tmp).catch((cleanupErr) => {
509
- console.error(` Warning: could not remove temp file ${tmp}: ${cleanupErr.message}`);
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 unlink(tmp).catch((cleanupErr) => {
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 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
+ }
520
817
  let existing = {};
521
818
  try {
522
- 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;
523
830
  } catch (err) {
524
831
  const code = err.code;
525
832
  if (code === "ENOENT") {
526
833
  } else if (err instanceof SyntaxError) {
527
- 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
+ );
528
841
  try {
529
- 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
+ }
530
861
  console.error(
531
- ` 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`
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
- ...entries
886
+ ...ops.create
546
887
  };
547
- if (!entries["tandem-channel"]) {
548
- if (merged["tandem-channel"]) {
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["tandem-channel"];
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 = 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 });
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(t.configPath, entries);
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(t.configPath, entries);
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 = join(PACKAGE_ROOT, ".claude-plugin", "plugin.json");
647
- 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:
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
- init_constants();
664
- init_skill_content();
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/channel/event-bridge.ts
1258
- async function startEventBridge(mcp, tandemUrl) {
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 connectAndStream(mcp, tandemUrl, lastEventId, (id) => {
1264
- lastEventId = id;
1265
- retries = 0;
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
- `[Channel] SSE connection failed (${retries}/${CHANNEL_MAX_RETRIES}):`,
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("[Channel] SSE connection exhausted, reporting error and exiting");
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: CHANNEL_CONNECT_FAILED,
1283
- 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.`
1284
1706
  })
1285
1707
  },
1286
1708
  CHANNEL_ERROR_REPORT_TIMEOUT_MS
1287
1709
  );
1288
1710
  } catch (reportErr) {
1289
1711
  console.error(
1290
- "[Channel] Could not report failure to server:",
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
- 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));
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 connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
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}`, { headers, signal: connectCtrl.signal });
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
- const AWARENESS_CLEAR_MS = 3e3;
1332
- function clearAwareness(documentId) {
1333
- fetchWithTimeout(
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
- "[Channel] clearAwareness failed (non-fatal):",
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
- fetchWithTimeout(
1361
- `${tandemUrl}${API_CHANNEL_AWARENESS}`,
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
- "[Channel] Awareness update failed:",
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
- if (clearAwarenessTimer) clearTimeout(clearAwarenessTimer);
1383
- 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
+ );
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 event;
1856
+ let raw;
1417
1857
  try {
1418
- event = parseTandemEvent(JSON.parse(data));
1419
- } catch {
1858
+ raw = JSON.parse(data);
1859
+ } catch (err) {
1420
1860
  console.error(
1421
- "[Channel] Malformed SSE event data (skipping), eventId=%s:",
1422
- eventId,
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
- "[Channel] Invalid SSE event structure (skipping), eventId=%s:",
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
- const mode = await getCachedMode(tandemUrl);
1439
- if (mode === "solo") {
1440
- console.error(`[Channel] Solo mode: suppressed ${event.type} event`);
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 mcp.notification({
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("[Channel] MCP notification failed (transport broken?):", err);
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 getCachedMode(tandemUrl) {
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
- const { mode } = await res.json();
1478
- cachedMode = mode;
1479
- } else {
1480
- console.error(`[Channel] Mode check returned ${res.status}, using cached: "${cachedMode}"`);
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
- "[Channel] Mode check failed, delivering event (fail-open):",
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
- cachedModeAt = now;
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
- var AWARENESS_DEBOUNCE_MS, MODE_CACHE_TTL_MS, cachedMode, cachedModeAt;
1493
- var init_event_bridge = __esm({
1494
- "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"() {
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
- 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;
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 envPaths from "env-paths";
2232
+ import envPaths2 from "env-paths";
1725
2233
  import fs2 from "fs";
1726
- import path3 from "path";
2234
+ import path4 from "path";
1727
2235
  function getTokenFilePath() {
1728
- return path3.join(envPaths("tandem", { suffix: "" }).data, TOKEN_FILE_NAME);
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 path4 from "path";
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 = path4.dirname(tokenPath);
1794
- 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")}`);
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 existsSync2 } from "fs";
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 (!existsSync2(SERVER_DIST)) {
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.12.0" : "0.0.0-dev";
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