tandem-editor 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.18.0";
294
294
  if (typeof __APP_VERSION__ !== "undefined") return __APP_VERSION__;
295
295
  for (const rel of ["../../package.json", "../../../package.json"]) {
296
296
  try {
@@ -1238,7 +1238,7 @@ function printPushStatus() {
1238
1238
 
1239
1239
  ` : "";
1240
1240
  console.error(
1241
- "\n\x1B[1mReal-time push notifications:\x1B[0m\n" + (channelRegistered ? " \x1B[32mEnabled\x1B[0m \u2014 the channel shim is registered; Claude Code receives events in real time.\n Relaunch any Claude Code session you started manually so it picks up the new server.\n\n" : " \x1B[33mUnavailable\x1B[0m \u2014 dist/channel/index.js not found; Claude Code will poll via tandem_checkInbox.\n Run 'npm run build' and re-run setup to enable push.\n\n") + " A Tandem plugin is also published (skill + MCP; the real-time monitor it carries is\n forward-looking, pending Claude Code support):\n\n claude plugin marketplace add bloknayrb/tandem\n claude plugin install tandem@tandem-editor\n\n" + devInstructions
1241
+ "\n\x1B[1mReal-time push notifications:\x1B[0m\n" + (channelRegistered ? " \x1B[32mEnabled\x1B[0m \u2014 the channel shim is registered; Claude Code receives events in real time.\n Relaunch any Claude Code session you started manually so it picks up the new server.\n\n" : " \x1B[33mUnavailable\x1B[0m \u2014 dist/channel/index.js not found; Claude Code will poll via tandem_checkInbox.\n Run 'npm run build' and re-run setup to enable push.\n\n") + " A Tandem plugin is also published (skill + MCP + a real-time monitor that\n activates on Claude Code 2.1.212+ and needs no --dangerously-... flag):\n\n claude plugin marketplace add bloknayrb/tandem\n claude plugin install tandem@tandem-editor\n\n" + devInstructions
1242
1242
  );
1243
1243
  }
1244
1244
  var init_setup = __esm({
@@ -1822,7 +1822,7 @@ var init_types2 = __esm({
1822
1822
 
1823
1823
  // src/shared/types.ts
1824
1824
  import { z } from "zod";
1825
- var AnnotationTypeSchema, AnnotationStatusSchema, HighlightColorSchema, SeveritySchema, TandemModeSchema, AuthorSchema, ReplyAuthorSchema, AnnotationActionSchema, ExportFormatSchema, DocumentFormatSchema, ToolErrorCodeSchema, ChannelErrorCodeSchema, CHANNEL_CONNECT_FAILED;
1825
+ var AnnotationTypeSchema, AnnotationStatusSchema, HighlightColorSchema, SeveritySchema, TandemModeSchema, AuthorSchema, ReplyAuthorSchema, AnnotationActionSchema, ExportFormatSchema, DocumentFormatSchema, ToolErrorCodeSchema, ChannelErrorCodeSchema, CHANNEL_CONNECT_FAILED, MONITOR_CONNECT_FAILED;
1826
1826
  var init_types3 = __esm({
1827
1827
  "src/shared/types.ts"() {
1828
1828
  "use strict";
@@ -1852,6 +1852,7 @@ var init_types3 = __esm({
1852
1852
  ]);
1853
1853
  ChannelErrorCodeSchema = z.enum(["CHANNEL_CONNECT_FAILED", "MONITOR_CONNECT_FAILED"]);
1854
1854
  CHANNEL_CONNECT_FAILED = "CHANNEL_CONNECT_FAILED";
1855
+ MONITOR_CONNECT_FAILED = "MONITOR_CONNECT_FAILED";
1855
1856
  }
1856
1857
  });
1857
1858
 
@@ -2155,6 +2156,63 @@ function refreshMode(tandemUrl, logPrefix) {
2155
2156
  cachedModeFailedAt = Date.now();
2156
2157
  });
2157
2158
  }
2159
+ async function flushFinalAwareness(tandemUrl, logPrefix = "[Tandem]") {
2160
+ if (shutdownTimers.awarenessTimer) clearTimeout(shutdownTimers.awarenessTimer);
2161
+ if (shutdownTimers.clearAwarenessTimer) clearTimeout(shutdownTimers.clearAwarenessTimer);
2162
+ if (outstandingAwareness.size > 0) {
2163
+ await Promise.allSettled(outstandingAwareness);
2164
+ }
2165
+ if (shutdownTimers.lastDocumentId === null) return true;
2166
+ try {
2167
+ const res = await fetchWithTimeout(
2168
+ `${tandemUrl}${API_CHANNEL_AWARENESS}`,
2169
+ {
2170
+ method: "POST",
2171
+ headers: { "Content-Type": "application/json" },
2172
+ body: JSON.stringify({
2173
+ documentId: shutdownTimers.lastDocumentId,
2174
+ status: "idle",
2175
+ active: false
2176
+ })
2177
+ },
2178
+ CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
2179
+ );
2180
+ if (!res.ok) {
2181
+ console.error(`${logPrefix} Shutdown awareness clear returned ${res.status}`);
2182
+ return false;
2183
+ }
2184
+ return true;
2185
+ } catch (err) {
2186
+ console.error(
2187
+ `${logPrefix} Shutdown awareness clear failed:`,
2188
+ describeFetchError(
2189
+ err,
2190
+ `${API_CHANNEL_AWARENESS} shutdown`,
2191
+ CHANNEL_AWARENESS_FETCH_TIMEOUT_MS
2192
+ )
2193
+ );
2194
+ return false;
2195
+ }
2196
+ }
2197
+ function _resetSseConsumerStateForTests() {
2198
+ cachedMode = TANDEM_MODE_DEFAULT;
2199
+ cachedModeAt = 0;
2200
+ cachedModeFailedAt = 0;
2201
+ _modeRefreshInFlight = null;
2202
+ shutdownTimers.awarenessTimer = null;
2203
+ shutdownTimers.clearAwarenessTimer = null;
2204
+ shutdownTimers.lastDocumentId = null;
2205
+ outstandingAwareness.clear();
2206
+ }
2207
+ function _setLastDocumentIdForTests(id) {
2208
+ shutdownTimers.lastDocumentId = id;
2209
+ }
2210
+ function _getLastDocumentIdForTests() {
2211
+ return shutdownTimers.lastDocumentId;
2212
+ }
2213
+ function _addOutstandingAwarenessForTests(p) {
2214
+ trackAwareness(p);
2215
+ }
2158
2216
  var AWARENESS_DEBOUNCE_MS, AWARENESS_CLEAR_MS, MODE_CACHE_TTL_MS, STABLE_CONNECTION_MS, RETRY_MAX_DELAY_MS, shutdownTimers, outstandingAwareness, cachedMode, cachedModeAt, cachedModeFailedAt, _modeRefreshInFlight;
2159
2217
  var init_sse_consumer = __esm({
2160
2218
  "src/shared/sse-consumer.ts"() {
@@ -2417,6 +2475,114 @@ var init_channel = __esm({
2417
2475
  }
2418
2476
  });
2419
2477
 
2478
+ // src/monitor/run.ts
2479
+ var run_exports = {};
2480
+ __export(run_exports, {
2481
+ _addOutstandingAwarenessForTests: () => _addOutstandingAwarenessForTests2,
2482
+ _getLastDocumentIdForTests: () => _getLastDocumentIdForTests2,
2483
+ _monitorTestExports: () => _monitorTestExports,
2484
+ _resetMonitorStateForTests: () => _resetMonitorStateForTests,
2485
+ _setLastDocumentIdForTests: () => _setLastDocumentIdForTests2,
2486
+ connectAndStream: () => connectAndStream,
2487
+ getCachedMode: () => getCachedMode2,
2488
+ getModeSync: () => getModeSync2,
2489
+ main: () => main,
2490
+ shutdownMonitor: () => shutdownMonitor
2491
+ });
2492
+ function buildOptions() {
2493
+ return {
2494
+ tandemUrl: TANDEM_URL,
2495
+ logPrefix: LOG_PREFIX,
2496
+ errorCode: MONITOR_CONNECT_FAILED,
2497
+ onEvent: (event) => {
2498
+ const content = formatEventContent(event).replace(/\n/g, " ");
2499
+ process.stdout.write(content + "\n");
2500
+ },
2501
+ onExhaustion: () => {
2502
+ process.stdout.write(
2503
+ "Tandem monitor disconnected \u2014 restart Tandem to restore real-time events\n"
2504
+ );
2505
+ }
2506
+ };
2507
+ }
2508
+ async function main() {
2509
+ installShutdownHandlers();
2510
+ installStdoutErrorHandler();
2511
+ console.error(`${LOG_PREFIX} Tandem monitor starting (server: ${TANDEM_URL})`);
2512
+ await getCachedMode2().catch(() => {
2513
+ });
2514
+ await runEventConsumer(buildOptions());
2515
+ }
2516
+ async function connectAndStream(lastEventId, onEventId, onStable = () => {
2517
+ }) {
2518
+ return connectAndStreamOnce(buildOptions(), lastEventId, { onEventId, onStable });
2519
+ }
2520
+ async function shutdownMonitor(signal) {
2521
+ console.error(`${LOG_PREFIX} Received ${signal}, clearing awareness and exiting`);
2522
+ const ok = await flushFinalAwareness(TANDEM_URL, LOG_PREFIX);
2523
+ process.exit(ok ? 0 : 1);
2524
+ }
2525
+ function _setLastDocumentIdForTests2(id) {
2526
+ _setLastDocumentIdForTests(id);
2527
+ }
2528
+ function _getLastDocumentIdForTests2() {
2529
+ return _getLastDocumentIdForTests();
2530
+ }
2531
+ function _addOutstandingAwarenessForTests2(p) {
2532
+ _addOutstandingAwarenessForTests(p);
2533
+ }
2534
+ function installShutdownHandlers() {
2535
+ if (IS_VITEST) return;
2536
+ const handler = (signal) => {
2537
+ shutdownMonitor(signal).catch((err) => {
2538
+ console.error(`${LOG_PREFIX} Shutdown handler failed:`, err);
2539
+ process.exit(1);
2540
+ });
2541
+ };
2542
+ process.on("SIGINT", () => handler("SIGINT"));
2543
+ process.on("SIGTERM", () => handler("SIGTERM"));
2544
+ }
2545
+ function onStdoutError(err) {
2546
+ console.error(`${LOG_PREFIX} stdout error (plugin-host pipe likely closed):`, err);
2547
+ process.exit(1);
2548
+ }
2549
+ function installStdoutErrorHandler() {
2550
+ if (IS_VITEST) return;
2551
+ process.stdout.on("error", onStdoutError);
2552
+ }
2553
+ async function getCachedMode2() {
2554
+ return getCachedMode(TANDEM_URL, LOG_PREFIX);
2555
+ }
2556
+ function getModeSync2() {
2557
+ return getModeSync();
2558
+ }
2559
+ function _resetMonitorStateForTests() {
2560
+ _resetSseConsumerStateForTests();
2561
+ process.removeAllListeners("SIGINT");
2562
+ process.removeAllListeners("SIGTERM");
2563
+ }
2564
+ var IS_VITEST, TANDEM_URL, LOG_PREFIX, _monitorTestExports;
2565
+ var init_run2 = __esm({
2566
+ "src/monitor/run.ts"() {
2567
+ "use strict";
2568
+ init_cli_runtime();
2569
+ init_types();
2570
+ init_sse_consumer();
2571
+ init_types3();
2572
+ IS_VITEST = process.env.VITEST === "true";
2573
+ if (!IS_VITEST) {
2574
+ console.log = console.error;
2575
+ console.warn = console.error;
2576
+ console.info = console.error;
2577
+ }
2578
+ TANDEM_URL = resolveTandemUrl();
2579
+ LOG_PREFIX = "[Monitor]";
2580
+ _monitorTestExports = {
2581
+ onStdoutError
2582
+ };
2583
+ }
2584
+ });
2585
+
2420
2586
  // src/server/annotations/lockfile.ts
2421
2587
  function parseLockfile(raw) {
2422
2588
  const trimmed = raw.trim();
@@ -2447,8 +2613,12 @@ var init_lockfile = __esm({
2447
2613
  // src/cli/doctor.ts
2448
2614
  var doctor_exports = {};
2449
2615
  __export(doctor_exports, {
2616
+ evaluateNpmStaleness: () => evaluateNpmStaleness,
2617
+ evaluateOrphanedVite: () => evaluateOrphanedVite,
2450
2618
  evaluateStaleGlobal: () => evaluateStaleGlobal,
2451
2619
  globalTandemEditorVersion: () => globalTandemEditorVersion,
2620
+ isTandemEditorRepo: () => isTandemEditorRepo,
2621
+ probeTandemEditorRepo: () => probeTandemEditorRepo,
2452
2622
  runDoctor: () => runDoctor,
2453
2623
  runDoctorCli: () => runDoctorCli,
2454
2624
  summarizeDoctorResults: () => summarizeDoctorResults
@@ -2459,6 +2629,10 @@ import { request } from "http";
2459
2629
  import { createConnection as createConnection2 } from "net";
2460
2630
  import { homedir as homedir3, platform } from "os";
2461
2631
  import { join as join5 } from "path";
2632
+ function errorClass(err) {
2633
+ if (err instanceof Error) return err.name;
2634
+ return typeof err;
2635
+ }
2462
2636
  function checkNodeVersion(r) {
2463
2637
  const version2 = process.version;
2464
2638
  const major = Number.parseInt(version2.slice(1), 10);
@@ -2478,6 +2652,204 @@ function checkNodeModules(r) {
2478
2652
  r.fail("node_modules/ not found", "npm install");
2479
2653
  }
2480
2654
  }
2655
+ function recordEvaluation(r, result) {
2656
+ if (!result) return;
2657
+ if (result.status === "warn") {
2658
+ r.warn(result.message, result.fix, result.data);
2659
+ return;
2660
+ }
2661
+ if (result.status === "skip") {
2662
+ r.pass(`skipped \u2014 ${result.message}`, result.fix, { ...result.data, skipped: true });
2663
+ return;
2664
+ }
2665
+ r.pass(result.message, result.fix, result.data);
2666
+ }
2667
+ function probeTandemEditorRepo(dir) {
2668
+ const read = readJson(join5(dir, "package.json"));
2669
+ if (read.kind === "absent") return "no";
2670
+ if (read.kind === "unreadable") return "unreadable";
2671
+ const parsed = read.value;
2672
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2673
+ return "unreadable";
2674
+ }
2675
+ return parsed.name === "tandem-editor" ? "yes" : "no";
2676
+ }
2677
+ function isTandemEditorRepo(dir) {
2678
+ return probeTandemEditorRepo(dir) === "yes";
2679
+ }
2680
+ function readJson(path8) {
2681
+ let raw;
2682
+ try {
2683
+ raw = readFileSync3(path8, "utf-8");
2684
+ } catch (err) {
2685
+ const code = err.code;
2686
+ if (code === "ENOENT") return { kind: "absent" };
2687
+ return { kind: "unreadable", reason: code ?? errorClass(err) };
2688
+ }
2689
+ try {
2690
+ return { kind: "ok", value: JSON.parse(raw) };
2691
+ } catch {
2692
+ return { kind: "unreadable", reason: "not valid JSON" };
2693
+ }
2694
+ }
2695
+ function parseLockfileEntry(value) {
2696
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
2697
+ const entry = value;
2698
+ if (entry.version !== void 0 && typeof entry.version !== "string") return null;
2699
+ if (entry.optional !== void 0 && typeof entry.optional !== "boolean") return null;
2700
+ return {
2701
+ version: entry.version,
2702
+ optional: entry.optional
2703
+ };
2704
+ }
2705
+ function parseLockfileJson(value) {
2706
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
2707
+ const obj = value;
2708
+ if (obj.version !== void 0 && typeof obj.version !== "string") return null;
2709
+ const version2 = obj.version;
2710
+ if (obj.packages === void 0) return { version: version2 };
2711
+ if (typeof obj.packages !== "object" || obj.packages === null || Array.isArray(obj.packages)) {
2712
+ return null;
2713
+ }
2714
+ const packages = {};
2715
+ for (const [path8, raw] of Object.entries(obj.packages)) {
2716
+ const entry = parseLockfileEntry(raw);
2717
+ if (!entry) return null;
2718
+ packages[path8] = entry;
2719
+ }
2720
+ return { version: version2, packages };
2721
+ }
2722
+ function evaluateNpmStaleness(pkgInput, lockInput, hiddenLockInput) {
2723
+ if (!pkgInput || !lockInput || !hiddenLockInput) return null;
2724
+ const lock = parseLockfileJson(lockInput);
2725
+ if (!lock) {
2726
+ return {
2727
+ status: "skip",
2728
+ message: "cannot compare (package-lock.json has an unexpected structure)",
2729
+ fix: "Restore it from git: git checkout package-lock.json",
2730
+ data: { reason: "malformed-lock" }
2731
+ };
2732
+ }
2733
+ const hiddenLock = parseLockfileJson(hiddenLockInput);
2734
+ if (!hiddenLock) {
2735
+ return {
2736
+ status: "skip",
2737
+ message: "cannot compare (node_modules/.package-lock.json has an unexpected structure)",
2738
+ fix: "npm install",
2739
+ data: { reason: "malformed-hidden-lock" }
2740
+ };
2741
+ }
2742
+ const pkg = typeof pkgInput === "object" && !Array.isArray(pkgInput) ? pkgInput : {};
2743
+ const pkgVersion = typeof pkg.version === "string" ? pkg.version : void 0;
2744
+ if (pkgVersion && lock.version && pkgVersion !== lock.version) {
2745
+ return {
2746
+ status: "warn",
2747
+ message: `package-lock.json (v${lock.version}) is out of date with package.json (v${pkgVersion})`,
2748
+ fix: "npm install",
2749
+ data: { packageVersion: pkgVersion, lockVersion: lock.version }
2750
+ };
2751
+ }
2752
+ if (lock.version && hiddenLock.version && lock.version !== hiddenLock.version) {
2753
+ return {
2754
+ status: "warn",
2755
+ message: `node_modules was installed from a different lockfile (v${hiddenLock.version} installed, v${lock.version} expected)`,
2756
+ fix: "npm install",
2757
+ data: { lockVersion: lock.version, installedVersion: hiddenLock.version }
2758
+ };
2759
+ }
2760
+ const wanted = lock.packages ?? {};
2761
+ const installed = hiddenLock.packages ?? {};
2762
+ const wantedCount = Object.keys(wanted).filter((path8) => path8 !== "").length;
2763
+ if (wantedCount === 0) {
2764
+ return {
2765
+ status: "skip",
2766
+ message: "cannot compare (package-lock.json lists no packages \u2014 a lockfileVersion 1 file, or one written by something other than npm)",
2767
+ fix: "npm install",
2768
+ // No inferred `lockfileVersion` here: we did not read that field, and
2769
+ // guessing it from the presence of `packages` would put a fabricated
2770
+ // value under a real npm field name.
2771
+ data: { reason: "no-comparable-packages" }
2772
+ };
2773
+ }
2774
+ const drifted = [];
2775
+ for (const [path8, entry] of Object.entries(wanted)) {
2776
+ if (path8 === "") continue;
2777
+ const got = installed[path8];
2778
+ if (!got) {
2779
+ if (!entry.optional) drifted.push(path8);
2780
+ } else if (entry.version !== got.version) {
2781
+ drifted.push(path8);
2782
+ }
2783
+ }
2784
+ for (const path8 of Object.keys(installed)) {
2785
+ if (path8 !== "" && !(path8 in wanted)) drifted.push(path8);
2786
+ }
2787
+ if (drifted.length > 0) {
2788
+ return {
2789
+ status: "warn",
2790
+ message: `node_modules is stale \u2014 ${drifted.length} package(s) differ from package-lock.json (e.g. after a pull or branch switch)`,
2791
+ fix: "npm install",
2792
+ data: { driftCount: drifted.length, sample: drifted.slice(0, 5) }
2793
+ };
2794
+ }
2795
+ const missingVersions = [];
2796
+ if (!pkgVersion) missingVersions.push("package.json");
2797
+ if (!lock.version) missingVersions.push("package-lock.json");
2798
+ if (!hiddenLock.version) missingVersions.push("node_modules/.package-lock.json");
2799
+ if (missingVersions.length > 0) {
2800
+ return {
2801
+ status: "skip",
2802
+ message: `node_modules matches package-lock.json, but the version check could not run \u2014 no "version" field in ${missingVersions.join(", ")}`,
2803
+ data: { reason: "no-comparable-version", missingVersions }
2804
+ };
2805
+ }
2806
+ return {
2807
+ status: "pass",
2808
+ message: "node_modules matches package-lock.json",
2809
+ data: { packageCount: Object.keys(installed).length }
2810
+ };
2811
+ }
2812
+ function readLockfileOrReport(r, path8, label) {
2813
+ const read = readJson(path8);
2814
+ if (read.kind === "absent") {
2815
+ r.pass(`skipped \u2014 cannot compare (${label} not found)`, void 0, {
2816
+ skipped: true,
2817
+ reason: "absent",
2818
+ path: label
2819
+ });
2820
+ return { ok: false };
2821
+ }
2822
+ if (read.kind === "ok") {
2823
+ if (typeof read.value === "object" && read.value !== null && !Array.isArray(read.value)) {
2824
+ return { ok: true, value: read.value };
2825
+ }
2826
+ r.warn(
2827
+ `${label} is not a JSON object \u2014 npm install staleness cannot be checked`,
2828
+ "Check for a truncated or hand-edited file, then: npm install",
2829
+ { reason: "not-an-object", path: label }
2830
+ );
2831
+ return { ok: false };
2832
+ }
2833
+ r.warn(
2834
+ `${label} could not be read (${read.reason}) \u2014 npm install staleness cannot be checked`,
2835
+ "Check for merge-conflict markers or a truncated file, then: npm install",
2836
+ { reason: read.reason, path: label }
2837
+ );
2838
+ return { ok: false };
2839
+ }
2840
+ function checkNpmStaleness(r, repoDir) {
2841
+ const pkgRead = readJson(join5(repoDir, "package.json"));
2842
+ const pkg = pkgRead.kind === "ok" ? pkgRead.value : null;
2843
+ const lock = readLockfileOrReport(r, join5(repoDir, "package-lock.json"), "package-lock.json");
2844
+ if (!lock.ok) return;
2845
+ const hiddenLock = readLockfileOrReport(
2846
+ r,
2847
+ join5(repoDir, "node_modules", ".package-lock.json"),
2848
+ "node_modules/.package-lock.json"
2849
+ );
2850
+ if (!hiddenLock.ok) return;
2851
+ recordEvaluation(r, evaluateNpmStaleness(pkg, lock.value, hiddenLock.value));
2852
+ }
2481
2853
  function checkMcpJson(r) {
2482
2854
  const mcpPath = join5(process.cwd(), ".mcp.json");
2483
2855
  if (!existsSync3(mcpPath)) {
@@ -2603,14 +2975,64 @@ async function checkPorts(r, wsPort, mcpPort) {
2603
2975
  }
2604
2976
  return { ws, mcp };
2605
2977
  }
2978
+ function evaluateOrphanedVite(input) {
2979
+ const { viteUp, viteConfirmed, wsUp, mcpUp, wsPort, mcpPort, vitePort } = input;
2980
+ if (!viteUp) return null;
2981
+ if (!viteConfirmed) {
2982
+ return {
2983
+ status: "skip",
2984
+ 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`,
2985
+ data: { vite: false, ws: wsUp, mcp: mcpUp, reason: "unconfirmed-vite" }
2986
+ };
2987
+ }
2988
+ if (!wsUp && !mcpUp) {
2989
+ return {
2990
+ status: "warn",
2991
+ message: `Vite dev server on :${vitePort} is running but the backend (:${wsPort} + :${mcpPort}) is down \u2014 likely orphaned by a crashed dev session`,
2992
+ 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`,
2993
+ data: { vite: true, ws: wsUp, mcp: mcpUp }
2994
+ };
2995
+ }
2996
+ if (wsUp !== mcpUp) {
2997
+ return {
2998
+ status: "skip",
2999
+ 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`,
3000
+ data: { vite: true, ws: wsUp, mcp: mcpUp, reason: "partial-backend" }
3001
+ };
3002
+ }
3003
+ return {
3004
+ status: "pass",
3005
+ message: `Vite dev server (:${vitePort}) running alongside the backend`,
3006
+ data: { vite: true, ws: wsUp, mcp: mcpUp }
3007
+ };
3008
+ }
3009
+ async function checkOrphanedVite(r, wsUp, mcpUp, wsPort, mcpPort, vitePort) {
3010
+ const viteUp = await probePort(vitePort);
3011
+ const viteConfirmed = viteUp ? await isViteDevServer(vitePort) : false;
3012
+ recordEvaluation(
3013
+ r,
3014
+ evaluateOrphanedVite({ viteUp, viteConfirmed, wsUp, mcpUp, wsPort, mcpPort, vitePort })
3015
+ );
3016
+ }
2606
3017
  function httpGet(url, timeoutMs = 3e3) {
2607
3018
  return new Promise((resolve4) => {
2608
3019
  const req = request(url, { timeout: timeoutMs }, (res) => {
2609
3020
  let body = "";
3021
+ let bytes = 0;
3022
+ let truncated = false;
2610
3023
  res.on("data", (chunk) => {
3024
+ if (truncated) return;
3025
+ bytes += Buffer.byteLength(chunk);
3026
+ if (bytes > HTTP_MAX_BYTES) {
3027
+ truncated = true;
3028
+ res.destroy();
3029
+ resolve4({ status: res.statusCode, data: null });
3030
+ return;
3031
+ }
2611
3032
  body += chunk;
2612
3033
  });
2613
3034
  res.on("end", () => {
3035
+ if (truncated) return;
2614
3036
  try {
2615
3037
  resolve4({ status: res.statusCode, data: JSON.parse(body) });
2616
3038
  } catch {
@@ -2626,6 +3048,10 @@ function httpGet(url, timeoutMs = 3e3) {
2626
3048
  req.end();
2627
3049
  });
2628
3050
  }
3051
+ async function isViteDevServer(port) {
3052
+ const result = await httpGet(`http://127.0.0.1:${port}/@vite/client`, 2e3);
3053
+ return result?.status === 200;
3054
+ }
2629
3055
  async function checkHealth(r, mcpPort) {
2630
3056
  const result = await httpGet(`http://127.0.0.1:${mcpPort}/health`);
2631
3057
  if (!result) {
@@ -2865,7 +3291,7 @@ function evaluateStaleGlobal(bundled, globalVersion) {
2865
3291
  };
2866
3292
  }
2867
3293
  async function checkStaleGlobal(r) {
2868
- const bundled = true ? "0.16.0" : null;
3294
+ const bundled = true ? "0.18.0" : null;
2869
3295
  if (!bundled) return;
2870
3296
  let globalVersion;
2871
3297
  try {
@@ -2884,16 +3310,37 @@ async function checkStaleGlobal(r) {
2884
3310
  async function runDoctor(opts = {}) {
2885
3311
  const wsPort = opts.wsPort ?? DEFAULT_WS_PORT;
2886
3312
  const mcpPort = opts.mcpPort ?? DEFAULT_MCP_PORT;
3313
+ const vitePort = opts.vitePort ?? VITE_DEV_PORT;
2887
3314
  const r = new Recorder();
3315
+ const cwd = process.cwd();
3316
+ const repo = probeTandemEditorRepo(cwd);
3317
+ const devRepo = repo === "yes";
2888
3318
  await r.check("node-version", () => checkNodeVersion(r));
2889
3319
  await r.check("node-modules", () => checkNodeModules(r));
3320
+ if (repo === "unreadable") {
3321
+ await r.check(
3322
+ "dev-repo",
3323
+ () => r.warn(
3324
+ "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",
3325
+ "Check for merge-conflict markers or a truncated file: git checkout package.json"
3326
+ )
3327
+ );
3328
+ }
3329
+ if (devRepo) {
3330
+ await r.check("npm-staleness", () => checkNpmStaleness(r, cwd));
3331
+ }
2890
3332
  await r.check("mcp-json", () => checkMcpJson(r));
2891
3333
  await r.check("user-mcp-config", () => checkUserMcpConfig(r));
2892
3334
  await r.check("annotation-store", () => checkAnnotationStore(r));
2893
3335
  await r.check("stale-global", () => checkStaleGlobal(r));
2894
- const { mcp } = await r.check("ports", () => checkPorts(r, wsPort, mcpPort));
3336
+ const ports = await r.check("ports", () => checkPorts(r, wsPort, mcpPort));
3337
+ const ws = ports?.ws ?? false;
3338
+ const mcp = ports?.mcp ?? false;
3339
+ if (devRepo) {
3340
+ await r.check("orphaned-vite", () => checkOrphanedVite(r, ws, mcp, wsPort, mcpPort, vitePort));
3341
+ }
2895
3342
  if (mcp) {
2896
- const healthy = await r.check("health", () => checkHealth(r, mcpPort));
3343
+ const healthy = await r.check("health", () => checkHealth(r, mcpPort)) ?? false;
2897
3344
  if (healthy) {
2898
3345
  await r.check("sse", () => checkSseEndpoint(r, mcpPort));
2899
3346
  }
@@ -2976,7 +3423,7 @@ async function runDoctorCli(opts = {}) {
2976
3423
  out("");
2977
3424
  return exitCode;
2978
3425
  }
2979
- var Recorder;
3426
+ var Recorder, VITE_DEV_PORT, HTTP_MAX_BYTES;
2980
3427
  var init_doctor = __esm({
2981
3428
  "src/cli/doctor.ts"() {
2982
3429
  "use strict";
@@ -2987,11 +3434,29 @@ var init_doctor = __esm({
2987
3434
  warnings = 0;
2988
3435
  results = [];
2989
3436
  currentCheck = "";
3437
+ /**
3438
+ * Run one check under `name`. A throwing check is contained here and
3439
+ * recorded as a `fail` rather than taking the whole report down.
3440
+ *
3441
+ * Returns `T | undefined` — NOT `T`. The `undefined` is load-bearing and
3442
+ * must not be cast away: a crashed check produced no value, and the two
3443
+ * callers that consume one (`ports` destructures `{ws, mcp}`; `health`
3444
+ * gates the `sse` check) have to say what they want instead. Returning
3445
+ * `undefined as T` would satisfy the compiler and then throw a TypeError
3446
+ * on the destructure — converting a clear crash into a confusing one on
3447
+ * the exact path this containment exists to protect.
3448
+ */
2990
3449
  async check(name, fn) {
2991
3450
  const prev = this.currentCheck;
2992
3451
  this.currentCheck = name;
2993
3452
  try {
2994
3453
  return await fn();
3454
+ } catch (err) {
3455
+ this.fail(
3456
+ `${name} check crashed (${errorClass(err)}) \u2014 the rest of the report is still valid`,
3457
+ "Please report this at https://github.com/bloknayrb/tandem/issues"
3458
+ );
3459
+ return void 0;
2995
3460
  } finally {
2996
3461
  this.currentCheck = prev;
2997
3462
  }
@@ -3014,6 +3479,8 @@ var init_doctor = __esm({
3014
3479
  this.record("fail", msg, fix, fields);
3015
3480
  }
3016
3481
  };
3482
+ VITE_DEV_PORT = 5173;
3483
+ HTTP_MAX_BYTES = 256 * 1024;
3017
3484
  }
3018
3485
  });
3019
3486
 
@@ -3696,7 +4163,7 @@ import fs4 from "fs";
3696
4163
  function knownVersion(v) {
3697
4164
  return typeof v === "string" && KNOWN_VERSION_MAJORS.has(v.split(".")[0]);
3698
4165
  }
3699
- function readJson(filePath) {
4166
+ function readJson2(filePath) {
3700
4167
  try {
3701
4168
  return JSON.parse(fs4.readFileSync(filePath, "utf-8"));
3702
4169
  } catch {
@@ -3709,7 +4176,7 @@ function resolveLicenseState(deps) {
3709
4176
  return { gateActive: false };
3710
4177
  }
3711
4178
  const nowMs = now();
3712
- const lf = readJson(licenseFilePath(appDataDir));
4179
+ const lf = readJson2(licenseFilePath(appDataDir));
3713
4180
  if (lf?.blob) {
3714
4181
  try {
3715
4182
  const meta = verify(lf.blob);
@@ -3726,7 +4193,7 @@ function resolveLicenseState(deps) {
3726
4193
  } catch {
3727
4194
  }
3728
4195
  }
3729
- const tf = readJson(trialFilePath(appDataDir));
4196
+ const tf = readJson2(trialFilePath(appDataDir));
3730
4197
  const firstRunAt = tf?.firstRunAt ? new Date(tf.firstRunAt).getTime() : nowMs;
3731
4198
  const expiresAt = firstRunAt + TRIAL_MS;
3732
4199
  if (nowMs < expiresAt) {
@@ -3917,7 +4384,7 @@ process.once("unhandledRejection", (reason) => {
3917
4384
  `);
3918
4385
  process.exit(1);
3919
4386
  });
3920
- var version = true ? "0.16.0" : "0.0.0-dev";
4387
+ var version = true ? "0.18.0" : "0.0.0-dev";
3921
4388
  var args = process.argv.slice(2);
3922
4389
  if (args.includes("--help") || args.includes("-h")) {
3923
4390
  console.log(`tandem v${version}
@@ -3945,6 +4412,10 @@ Usage:
3945
4412
  tandem server running on the host)
3946
4413
  tandem channel Run the Tandem channel shim (stdio MCP)
3947
4414
  (used by the plugin's tandem-channel entry)
4415
+ tandem monitor Run the Tandem plugin monitor: stream event
4416
+ lines to stdout for Claude Code to surface as
4417
+ notifications (used by the plugin's
4418
+ experimental.monitors entry; needs no flag)
3948
4419
  tandem --version
3949
4420
  tandem --help
3950
4421
  `);
@@ -3954,7 +4425,7 @@ if (args.includes("--version") || args.includes("-v")) {
3954
4425
  console.log(version);
3955
4426
  process.exit(0);
3956
4427
  }
3957
- var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
4428
+ var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel" || args[0] === "monitor";
3958
4429
  if (!isStdioMode) {
3959
4430
  const { default: updateNotifier } = await import("update-notifier");
3960
4431
  updateNotifier({ pkg: { name: "tandem-editor", version } }).notify();
@@ -3990,6 +4461,9 @@ try {
3990
4461
  } else if (args[0] === "channel") {
3991
4462
  const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
3992
4463
  await runChannelCli2();
4464
+ } else if (args[0] === "monitor") {
4465
+ const { main: main2 } = await Promise.resolve().then(() => (init_run2(), run_exports));
4466
+ await main2();
3993
4467
  } else if (args[0] === "doctor") {
3994
4468
  const { runDoctorCli: runDoctorCli2 } = await Promise.resolve().then(() => (init_doctor(), doctor_exports));
3995
4469
  const exitCode = await runDoctorCli2({ json: args.includes("--json") });