tandem-editor 0.17.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tandem",
3
- "version": "0.17.0",
3
+ "version": "0.18.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.17.0", "mcp-stdio"],
14
+ "args": ["-y", "tandem-editor@0.18.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.17.0", "channel"],
21
+ "args": ["-y", "tandem-editor@0.18.0", "channel"],
22
22
  "env": {
23
23
  "TANDEM_URL": "http://127.0.0.1:3479"
24
24
  }
@@ -28,7 +28,7 @@
28
28
  "monitors": [
29
29
  {
30
30
  "name": "tandem-events",
31
- "command": "node ${CLAUDE_PLUGIN_ROOT}/dist/monitor/index.js",
31
+ "command": "npx -y tandem-editor@0.18.0 monitor",
32
32
  "description": "Tandem real-time document events (annotations, chat, selections)"
33
33
  }
34
34
  ]
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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
+ ## [0.18.0] - 2026-07-19
9
+
10
+ ### Added
11
+
12
+ - **Real-time push now works for manually-launched Claude Code sessions, through the plugin monitor (#1201).** Until now, a Claude Code session received live document events (annotations, chat, selections) only if Tandem's own auto-launcher started it with a special flag — a session you started yourself by hand got nothing and had to poll for changes. The Tandem plugin now ships a *monitor* that Claude Code 2.1.212+ activates at launch and that pushes those events with no flag required. Install it once — `claude plugin marketplace add bloknayrb/tandem`, then `claude plugin install tandem@tandem-editor` — and every `claude` you start afterward wakes on document changes. (This is a Claude Code plugin capability; the desktop app and Cowork keep using their existing transports and are unchanged by this release.)
13
+
14
+ One caveat: if you install the plugin *and* also launch with `--dangerously-load-development-channels server:tandem-channel`, both transports deliver each event and you'll see it twice. For a hand-launched session, install the plugin and drop the flag.
15
+
16
+ ### Fixed
17
+
18
+ - **The server no longer crashes when an event subscriber's connection drops abruptly (#1202).** The `/api/events` push stream sends a periodic keepalive; if the client socket had already died, that write threw from inside a timer where nothing caught it, and the unhandled error took the whole server process down. The keepalive and event writes are now guarded and clean up the dead subscriber instead of crashing.
19
+
20
+ ### Internal
21
+
22
+ - **The `tandem_checkInbox` tool description now explains why to poll steadily (#1200).** A session can't tell from the server whether real-time push is actually reaching it, so the guidance to poll at a steady cadence — plus the note that polling dedups against already-pushed items, making it cheap and safe — now lives in the tool description itself. That reaches every MCP client, not only the ones that installed the Tandem skill.
23
+
8
24
  ## [0.17.0] - 2026-07-17
9
25
 
10
26
  ### Changed
@@ -19,8 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
35
 
20
36
  - **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
37
 
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
38
  - **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
39
 
26
40
  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.
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.17.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();
@@ -3125,7 +3291,7 @@ function evaluateStaleGlobal(bundled, globalVersion) {
3125
3291
  };
3126
3292
  }
3127
3293
  async function checkStaleGlobal(r) {
3128
- const bundled = true ? "0.17.0" : null;
3294
+ const bundled = true ? "0.18.0" : null;
3129
3295
  if (!bundled) return;
3130
3296
  let globalVersion;
3131
3297
  try {
@@ -4218,7 +4384,7 @@ process.once("unhandledRejection", (reason) => {
4218
4384
  `);
4219
4385
  process.exit(1);
4220
4386
  });
4221
- var version = true ? "0.17.0" : "0.0.0-dev";
4387
+ var version = true ? "0.18.0" : "0.0.0-dev";
4222
4388
  var args = process.argv.slice(2);
4223
4389
  if (args.includes("--help") || args.includes("-h")) {
4224
4390
  console.log(`tandem v${version}
@@ -4246,6 +4412,10 @@ Usage:
4246
4412
  tandem server running on the host)
4247
4413
  tandem channel Run the Tandem channel shim (stdio MCP)
4248
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)
4249
4419
  tandem --version
4250
4420
  tandem --help
4251
4421
  `);
@@ -4255,7 +4425,7 @@ if (args.includes("--version") || args.includes("-v")) {
4255
4425
  console.log(version);
4256
4426
  process.exit(0);
4257
4427
  }
4258
- var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
4428
+ var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel" || args[0] === "monitor";
4259
4429
  if (!isStdioMode) {
4260
4430
  const { default: updateNotifier } = await import("update-notifier");
4261
4431
  updateNotifier({ pkg: { name: "tandem-editor", version } }).notify();
@@ -4291,6 +4461,9 @@ try {
4291
4461
  } else if (args[0] === "channel") {
4292
4462
  const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
4293
4463
  await runChannelCli2();
4464
+ } else if (args[0] === "monitor") {
4465
+ const { main: main2 } = await Promise.resolve().then(() => (init_run2(), run_exports));
4466
+ await main2();
4294
4467
  } else if (args[0] === "doctor") {
4295
4468
  const { runDoctorCli: runDoctorCli2 } = await Promise.resolve().then(() => (init_doctor(), doctor_exports));
4296
4469
  const exitCode = await runDoctorCli2({ json: args.includes("--json") });