tandem-editor 0.16.0 → 0.17.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tandem",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Edit and iterate on documents with any MCP-capable AI. Claude is the default and best-supported client today.",
5
5
  "author": {
6
6
  "name": "Tandem"
@@ -11,14 +11,14 @@
11
11
  "mcpServers": {
12
12
  "tandem": {
13
13
  "command": "npx",
14
- "args": ["-y", "tandem-editor@0.16.0", "mcp-stdio"],
14
+ "args": ["-y", "tandem-editor@0.17.0", "mcp-stdio"],
15
15
  "env": {
16
16
  "TANDEM_URL": "http://127.0.0.1:3479"
17
17
  }
18
18
  },
19
19
  "tandem-channel": {
20
20
  "command": "npx",
21
- "args": ["-y", "tandem-editor@0.16.0", "channel"],
21
+ "args": ["-y", "tandem-editor@0.17.0", "channel"],
22
22
  "env": {
23
23
  "TANDEM_URL": "http://127.0.0.1:3479"
24
24
  }
package/CHANGELOG.md CHANGED
@@ -5,7 +5,30 @@ All notable changes to Tandem will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.17.0] - 2026-07-17
9
+
10
+ ### Changed
11
+
12
+ - **New-tab search is now fuzzy and ranked by match quality (#1186).** The new-tab menu's file search converges on the same fuzzy-match stack the command palette uses: results rank by match quality rather than pure recency (equal scores keep recency order), name matches outrank directory matches, and subsequence matches are now included (`chp` finds `chapter.md`). A directory-only match keeps its row but leaves the filename unhighlighted.
13
+
14
+ ### Fixed
15
+
16
+ - **The editor no longer throws an unhandled error when focus leaves the document (#1195).** Four surfaces — the status pill's word count, the formatting toolbar, the formatting bar, and find/replace's match count — kept themselves in sync with the editor by writing Svelte state directly from a Tiptap event callback. Tiptap emits those callbacks synchronously from ProseMirror's dispatch, and a native `blur` can arrive while the UI is mid-render, so the write landed inside an active Svelte reaction and threw. Despite the error text naming only `$derived`/`$inspect`/template expressions, an ordinary `{#if}` block is enough to trigger it, and the throw is not dev-only — it escapes through ProseMirror's dispatch in packaged builds too. All four now bridge through a shared microtask hop, which also collapses a burst of transactions into a single update instead of recomputing on every cursor move.
17
+
18
+ - **Settings controls no longer lie about being editable when the store is read-only (#1186).** When the on-disk settings `schemaVersion` is newer than the running build, Tandem opens settings read-only and silently drops writes — but the toggles and radios still flipped visually and then went stale, so the UI disagreed with what was saved. Every settings control across all tabs and the popover now shows a genuine disabled state, with a surface-wide banner explaining why, instead of accepting input it can't persist. (Display-name and actions that don't write settings stay live.)
19
+
20
+ - **Keyboard activation works again in the Cowork "admin declined" dialog (#1186).** The dialog sits inside its backdrop, whose unguarded keydown handler called `preventDefault()` on Enter/Space as they bubbled up from the dialog's own buttons and the Learn-more link — so every control in that dialog was dead to the keyboard, and pressing a key dismissed the dialog out from under you. The backdrop now only acts on events that originate on itself.
21
+
22
+ - **The text selection popup no longer paints a stray frosted-glass rectangle behind itself (#1189).** Selecting text in the desktop app drew a blurred rectangle spanning the popup's whole bounding box — including the empty space beside the narrower Annotate row, where no chrome is actually visible. The shared `.tandem-floating-pill` recipe carried a `backdrop-filter: blur(8px)` that every other pill hides behind its opaque background; the selection popup's shell is deliberately transparent (the two-capsule design), so there the blur had nothing to hide behind. The blur is now gone from the recipe, where it was invisible dead weight on every other surface anyway. The popup's capsules keep their background, border, shadow, and the gap the editor shows through — unchanged.
23
+
24
+ - **The text selection popup no longer paints a stray frosted-glass rectangle behind itself (#1189).** Selecting text in the desktop app drew a blurred rectangle spanning the popup's whole bounding box — including the empty space beside the narrower Annotate row, where no chrome is actually visible. The shared `.tandem-floating-pill` recipe carried a `backdrop-filter: blur(8px)` that every other pill hides behind its opaque background; the selection popup's shell is deliberately transparent (the two-capsule design), so there the blur had nothing to hide behind. The blur is now gone from the recipe, where it was invisible dead weight on every other surface anyway. The popup's capsules keep their background, border, shadow, and the gap the editor shows through — unchanged.
25
+
26
+ Only production builds were affected, which is why it survived since the two-capsule popup landed in #798: that redesign shipped with a `backdrop-filter: none` reset on the shell meant to cancel the recipe's blur, and the reset never survived minification. lightningcss (Vite's default CSS minifier) collapses a `backdrop-filter` + `-webkit-backdrop-filter` pair into the `-webkit-` form alone, and Chromium does not honour the prefixed form, so the reset silently evaporated in the packaged app while working fine in dev. That minifier hazard is tracked separately in #1188; a regression test now forbids both the blur on the recipe and the prefixed-pair pattern that hid it.
27
+
28
+ ### Internal
29
+
30
+ - **`tandem doctor` gains two dev-checkout diagnostics (#1192).** A staleness check compares `package.json` / `package-lock.json` against the tree npm actually installed and warns (fix: `npm install`) when they disagree — but only reports PASS after a comparison that compared something, recording an explicit "skipped" result for lockfile shapes it can't compare (v1/v2 lockfiles, root-only maps) rather than an unearned green. An orphaned-Vite check warns when :5173 is serving while both backend ports are down — the "editor loads but nothing works" state a crashed `dev:standalone` leaves behind — after confirming the port really is Vite. Both self-gate to the `tandem-editor` checkout and never appear for an end user running `tandem doctor` in their own project.
31
+ - **A `release` skill codifies the six-surface version bump (#1191).** `.claude/skills/release/SKILL.md` records the full cut sequence: the six version surfaces (four CI-guarded, the two lockfiles not), the changelog and tag steps, and the draft-then-publish GitHub Release flow. The `dev-server` skill gains an orphaned-Vite recovery step, and three Windows/cross-platform gotchas were promoted into `CLAUDE.md`.
9
32
 
10
33
  ## [0.16.0] - 2026-07-13
11
34
 
package/dist/cli/index.js CHANGED
@@ -290,7 +290,7 @@ function resolveChannelDist(env = process.env, exists = existsSync) {
290
290
  return resolve2(PACKAGE_ROOT, "dist/channel/index.js");
291
291
  }
292
292
  function resolveCliVersion() {
293
- if (true) return "0.16.0";
293
+ if (true) return "0.17.0";
294
294
  if (typeof __APP_VERSION__ !== "undefined") return __APP_VERSION__;
295
295
  for (const rel of ["../../package.json", "../../../package.json"]) {
296
296
  try {
@@ -2447,8 +2447,12 @@ var init_lockfile = __esm({
2447
2447
  // src/cli/doctor.ts
2448
2448
  var doctor_exports = {};
2449
2449
  __export(doctor_exports, {
2450
+ evaluateNpmStaleness: () => evaluateNpmStaleness,
2451
+ evaluateOrphanedVite: () => evaluateOrphanedVite,
2450
2452
  evaluateStaleGlobal: () => evaluateStaleGlobal,
2451
2453
  globalTandemEditorVersion: () => globalTandemEditorVersion,
2454
+ isTandemEditorRepo: () => isTandemEditorRepo,
2455
+ probeTandemEditorRepo: () => probeTandemEditorRepo,
2452
2456
  runDoctor: () => runDoctor,
2453
2457
  runDoctorCli: () => runDoctorCli,
2454
2458
  summarizeDoctorResults: () => summarizeDoctorResults
@@ -2459,6 +2463,10 @@ import { request } from "http";
2459
2463
  import { createConnection as createConnection2 } from "net";
2460
2464
  import { homedir as homedir3, platform } from "os";
2461
2465
  import { join as join5 } from "path";
2466
+ function errorClass(err) {
2467
+ if (err instanceof Error) return err.name;
2468
+ return typeof err;
2469
+ }
2462
2470
  function checkNodeVersion(r) {
2463
2471
  const version2 = process.version;
2464
2472
  const major = Number.parseInt(version2.slice(1), 10);
@@ -2478,6 +2486,204 @@ function checkNodeModules(r) {
2478
2486
  r.fail("node_modules/ not found", "npm install");
2479
2487
  }
2480
2488
  }
2489
+ function recordEvaluation(r, result) {
2490
+ if (!result) return;
2491
+ if (result.status === "warn") {
2492
+ r.warn(result.message, result.fix, result.data);
2493
+ return;
2494
+ }
2495
+ if (result.status === "skip") {
2496
+ r.pass(`skipped \u2014 ${result.message}`, result.fix, { ...result.data, skipped: true });
2497
+ return;
2498
+ }
2499
+ r.pass(result.message, result.fix, result.data);
2500
+ }
2501
+ function probeTandemEditorRepo(dir) {
2502
+ const read = readJson(join5(dir, "package.json"));
2503
+ if (read.kind === "absent") return "no";
2504
+ if (read.kind === "unreadable") return "unreadable";
2505
+ const parsed = read.value;
2506
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2507
+ return "unreadable";
2508
+ }
2509
+ return parsed.name === "tandem-editor" ? "yes" : "no";
2510
+ }
2511
+ function isTandemEditorRepo(dir) {
2512
+ return probeTandemEditorRepo(dir) === "yes";
2513
+ }
2514
+ function readJson(path8) {
2515
+ let raw;
2516
+ try {
2517
+ raw = readFileSync3(path8, "utf-8");
2518
+ } catch (err) {
2519
+ const code = err.code;
2520
+ if (code === "ENOENT") return { kind: "absent" };
2521
+ return { kind: "unreadable", reason: code ?? errorClass(err) };
2522
+ }
2523
+ try {
2524
+ return { kind: "ok", value: JSON.parse(raw) };
2525
+ } catch {
2526
+ return { kind: "unreadable", reason: "not valid JSON" };
2527
+ }
2528
+ }
2529
+ function parseLockfileEntry(value) {
2530
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
2531
+ const entry = value;
2532
+ if (entry.version !== void 0 && typeof entry.version !== "string") return null;
2533
+ if (entry.optional !== void 0 && typeof entry.optional !== "boolean") return null;
2534
+ return {
2535
+ version: entry.version,
2536
+ optional: entry.optional
2537
+ };
2538
+ }
2539
+ function parseLockfileJson(value) {
2540
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
2541
+ const obj = value;
2542
+ if (obj.version !== void 0 && typeof obj.version !== "string") return null;
2543
+ const version2 = obj.version;
2544
+ if (obj.packages === void 0) return { version: version2 };
2545
+ if (typeof obj.packages !== "object" || obj.packages === null || Array.isArray(obj.packages)) {
2546
+ return null;
2547
+ }
2548
+ const packages = {};
2549
+ for (const [path8, raw] of Object.entries(obj.packages)) {
2550
+ const entry = parseLockfileEntry(raw);
2551
+ if (!entry) return null;
2552
+ packages[path8] = entry;
2553
+ }
2554
+ return { version: version2, packages };
2555
+ }
2556
+ function evaluateNpmStaleness(pkgInput, lockInput, hiddenLockInput) {
2557
+ if (!pkgInput || !lockInput || !hiddenLockInput) return null;
2558
+ const lock = parseLockfileJson(lockInput);
2559
+ if (!lock) {
2560
+ return {
2561
+ status: "skip",
2562
+ message: "cannot compare (package-lock.json has an unexpected structure)",
2563
+ fix: "Restore it from git: git checkout package-lock.json",
2564
+ data: { reason: "malformed-lock" }
2565
+ };
2566
+ }
2567
+ const hiddenLock = parseLockfileJson(hiddenLockInput);
2568
+ if (!hiddenLock) {
2569
+ return {
2570
+ status: "skip",
2571
+ message: "cannot compare (node_modules/.package-lock.json has an unexpected structure)",
2572
+ fix: "npm install",
2573
+ data: { reason: "malformed-hidden-lock" }
2574
+ };
2575
+ }
2576
+ const pkg = typeof pkgInput === "object" && !Array.isArray(pkgInput) ? pkgInput : {};
2577
+ const pkgVersion = typeof pkg.version === "string" ? pkg.version : void 0;
2578
+ if (pkgVersion && lock.version && pkgVersion !== lock.version) {
2579
+ return {
2580
+ status: "warn",
2581
+ message: `package-lock.json (v${lock.version}) is out of date with package.json (v${pkgVersion})`,
2582
+ fix: "npm install",
2583
+ data: { packageVersion: pkgVersion, lockVersion: lock.version }
2584
+ };
2585
+ }
2586
+ if (lock.version && hiddenLock.version && lock.version !== hiddenLock.version) {
2587
+ return {
2588
+ status: "warn",
2589
+ message: `node_modules was installed from a different lockfile (v${hiddenLock.version} installed, v${lock.version} expected)`,
2590
+ fix: "npm install",
2591
+ data: { lockVersion: lock.version, installedVersion: hiddenLock.version }
2592
+ };
2593
+ }
2594
+ const wanted = lock.packages ?? {};
2595
+ const installed = hiddenLock.packages ?? {};
2596
+ const wantedCount = Object.keys(wanted).filter((path8) => path8 !== "").length;
2597
+ if (wantedCount === 0) {
2598
+ return {
2599
+ status: "skip",
2600
+ message: "cannot compare (package-lock.json lists no packages \u2014 a lockfileVersion 1 file, or one written by something other than npm)",
2601
+ fix: "npm install",
2602
+ // No inferred `lockfileVersion` here: we did not read that field, and
2603
+ // guessing it from the presence of `packages` would put a fabricated
2604
+ // value under a real npm field name.
2605
+ data: { reason: "no-comparable-packages" }
2606
+ };
2607
+ }
2608
+ const drifted = [];
2609
+ for (const [path8, entry] of Object.entries(wanted)) {
2610
+ if (path8 === "") continue;
2611
+ const got = installed[path8];
2612
+ if (!got) {
2613
+ if (!entry.optional) drifted.push(path8);
2614
+ } else if (entry.version !== got.version) {
2615
+ drifted.push(path8);
2616
+ }
2617
+ }
2618
+ for (const path8 of Object.keys(installed)) {
2619
+ if (path8 !== "" && !(path8 in wanted)) drifted.push(path8);
2620
+ }
2621
+ if (drifted.length > 0) {
2622
+ return {
2623
+ status: "warn",
2624
+ message: `node_modules is stale \u2014 ${drifted.length} package(s) differ from package-lock.json (e.g. after a pull or branch switch)`,
2625
+ fix: "npm install",
2626
+ data: { driftCount: drifted.length, sample: drifted.slice(0, 5) }
2627
+ };
2628
+ }
2629
+ const missingVersions = [];
2630
+ if (!pkgVersion) missingVersions.push("package.json");
2631
+ if (!lock.version) missingVersions.push("package-lock.json");
2632
+ if (!hiddenLock.version) missingVersions.push("node_modules/.package-lock.json");
2633
+ if (missingVersions.length > 0) {
2634
+ return {
2635
+ status: "skip",
2636
+ message: `node_modules matches package-lock.json, but the version check could not run \u2014 no "version" field in ${missingVersions.join(", ")}`,
2637
+ data: { reason: "no-comparable-version", missingVersions }
2638
+ };
2639
+ }
2640
+ return {
2641
+ status: "pass",
2642
+ message: "node_modules matches package-lock.json",
2643
+ data: { packageCount: Object.keys(installed).length }
2644
+ };
2645
+ }
2646
+ function readLockfileOrReport(r, path8, label) {
2647
+ const read = readJson(path8);
2648
+ if (read.kind === "absent") {
2649
+ r.pass(`skipped \u2014 cannot compare (${label} not found)`, void 0, {
2650
+ skipped: true,
2651
+ reason: "absent",
2652
+ path: label
2653
+ });
2654
+ return { ok: false };
2655
+ }
2656
+ if (read.kind === "ok") {
2657
+ if (typeof read.value === "object" && read.value !== null && !Array.isArray(read.value)) {
2658
+ return { ok: true, value: read.value };
2659
+ }
2660
+ r.warn(
2661
+ `${label} is not a JSON object \u2014 npm install staleness cannot be checked`,
2662
+ "Check for a truncated or hand-edited file, then: npm install",
2663
+ { reason: "not-an-object", path: label }
2664
+ );
2665
+ return { ok: false };
2666
+ }
2667
+ r.warn(
2668
+ `${label} could not be read (${read.reason}) \u2014 npm install staleness cannot be checked`,
2669
+ "Check for merge-conflict markers or a truncated file, then: npm install",
2670
+ { reason: read.reason, path: label }
2671
+ );
2672
+ return { ok: false };
2673
+ }
2674
+ function checkNpmStaleness(r, repoDir) {
2675
+ const pkgRead = readJson(join5(repoDir, "package.json"));
2676
+ const pkg = pkgRead.kind === "ok" ? pkgRead.value : null;
2677
+ const lock = readLockfileOrReport(r, join5(repoDir, "package-lock.json"), "package-lock.json");
2678
+ if (!lock.ok) return;
2679
+ const hiddenLock = readLockfileOrReport(
2680
+ r,
2681
+ join5(repoDir, "node_modules", ".package-lock.json"),
2682
+ "node_modules/.package-lock.json"
2683
+ );
2684
+ if (!hiddenLock.ok) return;
2685
+ recordEvaluation(r, evaluateNpmStaleness(pkg, lock.value, hiddenLock.value));
2686
+ }
2481
2687
  function checkMcpJson(r) {
2482
2688
  const mcpPath = join5(process.cwd(), ".mcp.json");
2483
2689
  if (!existsSync3(mcpPath)) {
@@ -2603,14 +2809,64 @@ async function checkPorts(r, wsPort, mcpPort) {
2603
2809
  }
2604
2810
  return { ws, mcp };
2605
2811
  }
2812
+ function evaluateOrphanedVite(input) {
2813
+ const { viteUp, viteConfirmed, wsUp, mcpUp, wsPort, mcpPort, vitePort } = input;
2814
+ if (!viteUp) return null;
2815
+ if (!viteConfirmed) {
2816
+ return {
2817
+ status: "skip",
2818
+ message: `cannot identify the process on :${vitePort} \u2014 it is listening but did not answer /@vite/client, so it is probably not a Vite dev server`,
2819
+ data: { vite: false, ws: wsUp, mcp: mcpUp, reason: "unconfirmed-vite" }
2820
+ };
2821
+ }
2822
+ if (!wsUp && !mcpUp) {
2823
+ return {
2824
+ status: "warn",
2825
+ message: `Vite dev server on :${vitePort} is running but the backend (:${wsPort} + :${mcpPort}) is down \u2014 likely orphaned by a crashed dev session`,
2826
+ fix: `If you meant to run the client alone (npm run dev:client), this is expected. Otherwise kill the process on :${vitePort} and restart: npm run dev:standalone`,
2827
+ data: { vite: true, ws: wsUp, mcp: mcpUp }
2828
+ };
2829
+ }
2830
+ if (wsUp !== mcpUp) {
2831
+ return {
2832
+ status: "skip",
2833
+ message: `cannot tell whether the Vite dev server on :${vitePort} is orphaned \u2014 the backend is only partially up (:${wsPort} ${wsUp ? "up" : "down"}, :${mcpPort} ${mcpUp ? "up" : "down"}); see the ports check`,
2834
+ data: { vite: true, ws: wsUp, mcp: mcpUp, reason: "partial-backend" }
2835
+ };
2836
+ }
2837
+ return {
2838
+ status: "pass",
2839
+ message: `Vite dev server (:${vitePort}) running alongside the backend`,
2840
+ data: { vite: true, ws: wsUp, mcp: mcpUp }
2841
+ };
2842
+ }
2843
+ async function checkOrphanedVite(r, wsUp, mcpUp, wsPort, mcpPort, vitePort) {
2844
+ const viteUp = await probePort(vitePort);
2845
+ const viteConfirmed = viteUp ? await isViteDevServer(vitePort) : false;
2846
+ recordEvaluation(
2847
+ r,
2848
+ evaluateOrphanedVite({ viteUp, viteConfirmed, wsUp, mcpUp, wsPort, mcpPort, vitePort })
2849
+ );
2850
+ }
2606
2851
  function httpGet(url, timeoutMs = 3e3) {
2607
2852
  return new Promise((resolve4) => {
2608
2853
  const req = request(url, { timeout: timeoutMs }, (res) => {
2609
2854
  let body = "";
2855
+ let bytes = 0;
2856
+ let truncated = false;
2610
2857
  res.on("data", (chunk) => {
2858
+ if (truncated) return;
2859
+ bytes += Buffer.byteLength(chunk);
2860
+ if (bytes > HTTP_MAX_BYTES) {
2861
+ truncated = true;
2862
+ res.destroy();
2863
+ resolve4({ status: res.statusCode, data: null });
2864
+ return;
2865
+ }
2611
2866
  body += chunk;
2612
2867
  });
2613
2868
  res.on("end", () => {
2869
+ if (truncated) return;
2614
2870
  try {
2615
2871
  resolve4({ status: res.statusCode, data: JSON.parse(body) });
2616
2872
  } catch {
@@ -2626,6 +2882,10 @@ function httpGet(url, timeoutMs = 3e3) {
2626
2882
  req.end();
2627
2883
  });
2628
2884
  }
2885
+ async function isViteDevServer(port) {
2886
+ const result = await httpGet(`http://127.0.0.1:${port}/@vite/client`, 2e3);
2887
+ return result?.status === 200;
2888
+ }
2629
2889
  async function checkHealth(r, mcpPort) {
2630
2890
  const result = await httpGet(`http://127.0.0.1:${mcpPort}/health`);
2631
2891
  if (!result) {
@@ -2865,7 +3125,7 @@ function evaluateStaleGlobal(bundled, globalVersion) {
2865
3125
  };
2866
3126
  }
2867
3127
  async function checkStaleGlobal(r) {
2868
- const bundled = true ? "0.16.0" : null;
3128
+ const bundled = true ? "0.17.0" : null;
2869
3129
  if (!bundled) return;
2870
3130
  let globalVersion;
2871
3131
  try {
@@ -2884,16 +3144,37 @@ async function checkStaleGlobal(r) {
2884
3144
  async function runDoctor(opts = {}) {
2885
3145
  const wsPort = opts.wsPort ?? DEFAULT_WS_PORT;
2886
3146
  const mcpPort = opts.mcpPort ?? DEFAULT_MCP_PORT;
3147
+ const vitePort = opts.vitePort ?? VITE_DEV_PORT;
2887
3148
  const r = new Recorder();
3149
+ const cwd = process.cwd();
3150
+ const repo = probeTandemEditorRepo(cwd);
3151
+ const devRepo = repo === "yes";
2888
3152
  await r.check("node-version", () => checkNodeVersion(r));
2889
3153
  await r.check("node-modules", () => checkNodeModules(r));
3154
+ if (repo === "unreadable") {
3155
+ await r.check(
3156
+ "dev-repo",
3157
+ () => r.warn(
3158
+ "package.json in the current directory could not be read \u2014 if this is the tandem-editor checkout, the npm-staleness and orphaned-Vite checks are being skipped",
3159
+ "Check for merge-conflict markers or a truncated file: git checkout package.json"
3160
+ )
3161
+ );
3162
+ }
3163
+ if (devRepo) {
3164
+ await r.check("npm-staleness", () => checkNpmStaleness(r, cwd));
3165
+ }
2890
3166
  await r.check("mcp-json", () => checkMcpJson(r));
2891
3167
  await r.check("user-mcp-config", () => checkUserMcpConfig(r));
2892
3168
  await r.check("annotation-store", () => checkAnnotationStore(r));
2893
3169
  await r.check("stale-global", () => checkStaleGlobal(r));
2894
- const { mcp } = await r.check("ports", () => checkPorts(r, wsPort, mcpPort));
3170
+ const ports = await r.check("ports", () => checkPorts(r, wsPort, mcpPort));
3171
+ const ws = ports?.ws ?? false;
3172
+ const mcp = ports?.mcp ?? false;
3173
+ if (devRepo) {
3174
+ await r.check("orphaned-vite", () => checkOrphanedVite(r, ws, mcp, wsPort, mcpPort, vitePort));
3175
+ }
2895
3176
  if (mcp) {
2896
- const healthy = await r.check("health", () => checkHealth(r, mcpPort));
3177
+ const healthy = await r.check("health", () => checkHealth(r, mcpPort)) ?? false;
2897
3178
  if (healthy) {
2898
3179
  await r.check("sse", () => checkSseEndpoint(r, mcpPort));
2899
3180
  }
@@ -2976,7 +3257,7 @@ async function runDoctorCli(opts = {}) {
2976
3257
  out("");
2977
3258
  return exitCode;
2978
3259
  }
2979
- var Recorder;
3260
+ var Recorder, VITE_DEV_PORT, HTTP_MAX_BYTES;
2980
3261
  var init_doctor = __esm({
2981
3262
  "src/cli/doctor.ts"() {
2982
3263
  "use strict";
@@ -2987,11 +3268,29 @@ var init_doctor = __esm({
2987
3268
  warnings = 0;
2988
3269
  results = [];
2989
3270
  currentCheck = "";
3271
+ /**
3272
+ * Run one check under `name`. A throwing check is contained here and
3273
+ * recorded as a `fail` rather than taking the whole report down.
3274
+ *
3275
+ * Returns `T | undefined` — NOT `T`. The `undefined` is load-bearing and
3276
+ * must not be cast away: a crashed check produced no value, and the two
3277
+ * callers that consume one (`ports` destructures `{ws, mcp}`; `health`
3278
+ * gates the `sse` check) have to say what they want instead. Returning
3279
+ * `undefined as T` would satisfy the compiler and then throw a TypeError
3280
+ * on the destructure — converting a clear crash into a confusing one on
3281
+ * the exact path this containment exists to protect.
3282
+ */
2990
3283
  async check(name, fn) {
2991
3284
  const prev = this.currentCheck;
2992
3285
  this.currentCheck = name;
2993
3286
  try {
2994
3287
  return await fn();
3288
+ } catch (err) {
3289
+ this.fail(
3290
+ `${name} check crashed (${errorClass(err)}) \u2014 the rest of the report is still valid`,
3291
+ "Please report this at https://github.com/bloknayrb/tandem/issues"
3292
+ );
3293
+ return void 0;
2995
3294
  } finally {
2996
3295
  this.currentCheck = prev;
2997
3296
  }
@@ -3014,6 +3313,8 @@ var init_doctor = __esm({
3014
3313
  this.record("fail", msg, fix, fields);
3015
3314
  }
3016
3315
  };
3316
+ VITE_DEV_PORT = 5173;
3317
+ HTTP_MAX_BYTES = 256 * 1024;
3017
3318
  }
3018
3319
  });
3019
3320
 
@@ -3696,7 +3997,7 @@ import fs4 from "fs";
3696
3997
  function knownVersion(v) {
3697
3998
  return typeof v === "string" && KNOWN_VERSION_MAJORS.has(v.split(".")[0]);
3698
3999
  }
3699
- function readJson(filePath) {
4000
+ function readJson2(filePath) {
3700
4001
  try {
3701
4002
  return JSON.parse(fs4.readFileSync(filePath, "utf-8"));
3702
4003
  } catch {
@@ -3709,7 +4010,7 @@ function resolveLicenseState(deps) {
3709
4010
  return { gateActive: false };
3710
4011
  }
3711
4012
  const nowMs = now();
3712
- const lf = readJson(licenseFilePath(appDataDir));
4013
+ const lf = readJson2(licenseFilePath(appDataDir));
3713
4014
  if (lf?.blob) {
3714
4015
  try {
3715
4016
  const meta = verify(lf.blob);
@@ -3726,7 +4027,7 @@ function resolveLicenseState(deps) {
3726
4027
  } catch {
3727
4028
  }
3728
4029
  }
3729
- const tf = readJson(trialFilePath(appDataDir));
4030
+ const tf = readJson2(trialFilePath(appDataDir));
3730
4031
  const firstRunAt = tf?.firstRunAt ? new Date(tf.firstRunAt).getTime() : nowMs;
3731
4032
  const expiresAt = firstRunAt + TRIAL_MS;
3732
4033
  if (nowMs < expiresAt) {
@@ -3917,7 +4218,7 @@ process.once("unhandledRejection", (reason) => {
3917
4218
  `);
3918
4219
  process.exit(1);
3919
4220
  });
3920
- var version = true ? "0.16.0" : "0.0.0-dev";
4221
+ var version = true ? "0.17.0" : "0.0.0-dev";
3921
4222
  var args = process.argv.slice(2);
3922
4223
  if (args.includes("--help") || args.includes("-h")) {
3923
4224
  console.log(`tandem v${version}