tandem-editor 0.6.2 → 0.7.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
@@ -10,7 +10,7 @@ var __export = (target, all) => {
10
10
  };
11
11
 
12
12
  // src/shared/constants.ts
13
- var DEFAULT_MCP_PORT, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS;
13
+ var DEFAULT_MCP_PORT, MAX_FILE_SIZE, MAX_WS_PAYLOAD, IDLE_TIMEOUT, SESSION_MAX_AGE, CHANNEL_MAX_RETRIES, CHANNEL_RETRY_DELAY_MS, TOKEN_FILE_NAME;
14
14
  var init_constants = __esm({
15
15
  "src/shared/constants.ts"() {
16
16
  "use strict";
@@ -21,6 +21,7 @@ var init_constants = __esm({
21
21
  SESSION_MAX_AGE = 30 * 24 * 60 * 60 * 1e3;
22
22
  CHANNEL_MAX_RETRIES = 5;
23
23
  CHANNEL_RETRY_DELAY_MS = 2e3;
24
+ TOKEN_FILE_NAME = "auth-token";
24
25
  }
25
26
  });
26
27
 
@@ -42,6 +43,7 @@ var init_skill_content = __esm({
42
43
  var setup_exports = {};
43
44
  __export(setup_exports, {
44
45
  applyConfig: () => applyConfig,
46
+ applyConfigWithToken: () => applyConfigWithToken,
45
47
  buildMcpEntries: () => buildMcpEntries,
46
48
  detectTargets: () => detectTargets,
47
49
  installSkill: () => installSkill,
@@ -55,14 +57,20 @@ import { homedir } from "os";
55
57
  import { basename, dirname as dirname2, join, resolve as resolve2 } from "path";
56
58
  import { fileURLToPath as fileURLToPath2 } from "url";
57
59
  function buildMcpEntries(channelPath, opts = {}) {
58
- const entries = {
59
- tandem: { type: "http", url: `${MCP_URL}/mcp` }
60
- };
60
+ const tandemEntry = { type: "http", url: `${MCP_URL}/mcp` };
61
+ if (opts.token) {
62
+ tandemEntry.headers = { Authorization: `Bearer ${opts.token}` };
63
+ }
64
+ const entries = { tandem: tandemEntry };
61
65
  if (opts.withChannelShim) {
66
+ const shimEnv = { TANDEM_URL: MCP_URL };
67
+ if (opts.token) {
68
+ shimEnv.TANDEM_AUTH_TOKEN = opts.token;
69
+ }
62
70
  entries["tandem-channel"] = {
63
71
  command: opts.nodeBinary ?? "node",
64
72
  args: [channelPath],
65
- env: { TANDEM_URL: MCP_URL }
73
+ env: shimEnv
66
74
  };
67
75
  }
68
76
  return entries;
@@ -157,6 +165,24 @@ async function installSkill(opts = {}) {
157
165
  function validateChannelShimPrereq(channelPath) {
158
166
  return existsSync(channelPath);
159
167
  }
168
+ async function applyConfigWithToken(token, opts = {}) {
169
+ const targets = detectTargets({ force: opts.force });
170
+ const entries = buildMcpEntries(CHANNEL_DIST, {
171
+ withChannelShim: opts.withChannelShim,
172
+ token: token ?? void 0
173
+ });
174
+ let updated = 0;
175
+ const errors = [];
176
+ for (const t of targets) {
177
+ try {
178
+ await applyConfig(t.configPath, entries);
179
+ updated++;
180
+ } catch (err) {
181
+ errors.push(`${t.label}: ${err instanceof Error ? err.message : String(err)}`);
182
+ }
183
+ }
184
+ return { updated, errors };
185
+ }
160
186
  async function runSetup(opts = {}) {
161
187
  console.error("\nTandem Setup\n");
162
188
  if (opts.withChannelShim && !validateChannelShimPrereq(CHANNEL_DIST)) {
@@ -249,15 +275,35 @@ function resolveTandemUrl(override) {
249
275
  const raw = override ?? process.env.TANDEM_URL ?? `http://localhost:${DEFAULT_MCP_PORT}`;
250
276
  return raw.replace(/\/$/, "");
251
277
  }
278
+ async function authFetch(url, init) {
279
+ const token = process.env.TANDEM_AUTH_TOKEN;
280
+ if (token !== void 0 && token.trim() !== "") {
281
+ if (VALID_TOKEN_RE.test(token.trim())) {
282
+ const headers = new Headers(init?.headers);
283
+ headers.set("Authorization", `Bearer ${token.trim()}`);
284
+ return fetch(url, { ...init, headers });
285
+ }
286
+ if (!_warnedInvalidToken) {
287
+ _warnedInvalidToken = true;
288
+ console.error(
289
+ "[tandem] authFetch: TANDEM_AUTH_TOKEN is set but invalid (must be 32+ alphanumeric chars [A-Za-z0-9_-]); sending without Authorization header"
290
+ );
291
+ }
292
+ }
293
+ return fetch(url, init);
294
+ }
295
+ var VALID_TOKEN_RE, _warnedInvalidToken;
252
296
  var init_cli_runtime = __esm({
253
297
  "src/shared/cli-runtime.ts"() {
254
298
  "use strict";
255
299
  init_constants();
300
+ VALID_TOKEN_RE = /^[A-Za-z0-9_\-]{32,}$/;
301
+ _warnedInvalidToken = false;
256
302
  }
257
303
  });
258
304
 
259
305
  // src/cli/preflight.ts
260
- async function ensureTandemServer(opts = {}) {
306
+ async function probeTandemServer(opts = {}) {
261
307
  const url = resolveTandemUrl(opts.url);
262
308
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
263
309
  const controller = new AbortController();
@@ -265,22 +311,36 @@ async function ensureTandemServer(opts = {}) {
265
311
  try {
266
312
  const res = await fetch(`${url}/health`, { signal: controller.signal });
267
313
  if (!res.ok) {
268
- fail(url, `health endpoint returned HTTP ${res.status}`);
314
+ return {
315
+ ok: false,
316
+ url,
317
+ reason: `health endpoint returned HTTP ${res.status}`,
318
+ kind: "unhealthy"
319
+ };
269
320
  }
321
+ return { ok: true };
270
322
  } catch (err) {
271
- const msg = err instanceof Error ? err.message : String(err);
272
- fail(url, msg);
323
+ return {
324
+ ok: false,
325
+ url,
326
+ reason: err instanceof Error ? err.message : String(err),
327
+ kind: "unreachable"
328
+ };
273
329
  } finally {
274
330
  clearTimeout(timer);
275
331
  }
276
332
  }
277
- function fail(url, detail) {
278
- process.stderr.write(
279
- `[tandem] Tandem server not reachable at ${url} (${detail}).
280
- [tandem] Start the Tauri app or run \`tandem start\` on the host, then retry.
333
+ async function ensureTandemServer(opts = {}) {
334
+ const probe = await probeTandemServer(opts);
335
+ if (!probe.ok) {
336
+ const guidance = probe.kind === "unreachable" ? "Start the Tauri app or run `tandem start` on the host, then retry." : "The Tandem server is running but unhealthy \u2014 check the host logs.";
337
+ process.stderr.write(
338
+ `[tandem] Tandem server preflight failed at ${probe.url} (${probe.reason}).
339
+ [tandem] ${guidance}
281
340
  `
282
- );
283
- process.exit(1);
341
+ );
342
+ process.exit(1);
343
+ }
284
344
  }
285
345
  var DEFAULT_TIMEOUT_MS;
286
346
  var init_preflight = __esm({
@@ -295,80 +355,237 @@ var init_preflight = __esm({
295
355
  var mcp_stdio_exports = {};
296
356
  __export(mcp_stdio_exports, {
297
357
  getRequestId: () => getRequestId,
358
+ getResponseId: () => getResponseId,
359
+ parseTimeoutMs: () => parseTimeoutMs,
360
+ readAndValidateAuthToken: () => readAndValidateAuthToken,
298
361
  runMcpStdio: () => runMcpStdio
299
362
  });
300
363
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
301
364
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
365
+ function parseTimeoutMs(raw) {
366
+ if (raw !== void 0) {
367
+ const parsed = parseInt(raw, 10);
368
+ if (Number.isFinite(parsed) && parsed > 0 && parsed <= MAX_TIMEOUT_MS) {
369
+ return parsed;
370
+ }
371
+ process.stderr.write(
372
+ `[tandem mcp-stdio] TANDEM_REQUEST_TIMEOUT_MS must be a positive integer \u2264 ${MAX_TIMEOUT_MS}; ignoring "${raw}", using 30000ms default
373
+ `
374
+ );
375
+ }
376
+ return 3e4;
377
+ }
378
+ function readAndValidateAuthToken() {
379
+ const raw = process.env.TANDEM_AUTH_TOKEN;
380
+ if (raw === void 0) return null;
381
+ const trimmed = raw.trim();
382
+ if (trimmed === "") return null;
383
+ if (trimmed.startsWith("Bearer ")) {
384
+ process.stderr.write(
385
+ "[tandem mcp-stdio] TANDEM_AUTH_TOKEN is invalid (double-prefix: do not include 'Bearer ' prefix \u2014 supply the raw token only)\n"
386
+ );
387
+ process.exit(1);
388
+ }
389
+ if (!VALID_TOKEN_RE2.test(trimmed)) {
390
+ process.stderr.write(
391
+ "[tandem mcp-stdio] TANDEM_AUTH_TOKEN is malformed (must be 32+ URL-safe characters: [A-Za-z0-9_-])\n"
392
+ );
393
+ process.exit(1);
394
+ }
395
+ return trimmed;
396
+ }
302
397
  async function runMcpStdio() {
303
398
  const baseUrl = resolveTandemUrl();
304
- await ensureTandemServer({ url: baseUrl });
305
- const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`));
399
+ const authToken = readAndValidateAuthToken();
400
+ const http = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), {
401
+ requestInit: authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : void 0
402
+ });
306
403
  const stdio = new StdioServerTransport();
404
+ const pendingRequests = /* @__PURE__ */ new Map();
405
+ const preReadyBuffer = [];
307
406
  let shuttingDown = false;
308
- const shutdown = async (code = 0) => {
309
- if (!shuttingDown) {
310
- shuttingDown = true;
311
- await http.close().catch(() => {
312
- });
313
- await stdio.close().catch(() => {
314
- });
407
+ let httpReady = false;
408
+ async function sendErrorResponse(id, message, detail) {
409
+ const errorResponse = {
410
+ jsonrpc: "2.0",
411
+ id,
412
+ error: {
413
+ // -32000 is the implementation-defined server error range per
414
+ // JSON-RPC 2.0 §5.1 — upstream unavailability is an application-
415
+ // level condition, not a generic Internal Error.
416
+ code: -32e3,
417
+ message,
418
+ ...detail !== void 0 ? { data: { detail } } : {}
419
+ }
420
+ };
421
+ try {
422
+ await stdio.send(errorResponse);
423
+ } catch (err) {
424
+ const detail2 = err instanceof Error ? err.message : String(err);
425
+ process.stderr.write(
426
+ `[tandem mcp-stdio] failed to send synthesized error for id ${id}: ${detail2}
427
+ `
428
+ );
429
+ }
430
+ }
431
+ function forwardToUpstream(msg) {
432
+ if (shuttingDown) return;
433
+ const requestId = getRequestId(msg);
434
+ if (requestId !== void 0) {
435
+ const existing = pendingRequests.get(requestId);
436
+ if (existing) clearTimeout(existing);
437
+ const timeoutHandle = setTimeout(() => {
438
+ if (!pendingRequests.delete(requestId)) return;
439
+ void sendErrorResponse(
440
+ requestId,
441
+ "Tandem HTTP upstream not responding (half-open)",
442
+ `No response after ${STDIO_REQUEST_TIMEOUT_MS}ms`
443
+ );
444
+ }, STDIO_REQUEST_TIMEOUT_MS);
445
+ pendingRequests.set(requestId, timeoutHandle);
315
446
  }
316
- process.exit(code);
317
- };
318
- stdio.onmessage = (msg) => {
319
447
  http.send(msg).catch((err) => {
320
448
  const detail = err instanceof Error ? err.message : String(err);
321
449
  process.stderr.write(`[tandem mcp-stdio] upstream send failed: ${detail}
322
450
  `);
323
- const requestId = getRequestId(msg);
324
451
  if (requestId !== void 0) {
325
- const errorResponse = {
326
- jsonrpc: "2.0",
327
- id: requestId,
328
- error: {
329
- // -32000 is the implementation-defined server error range per
330
- // JSON-RPC 2.0 §5.1 — the upstream being unreachable is an
331
- // application-level condition, not a generic Internal Error.
332
- code: -32e3,
333
- message: "Tandem HTTP upstream unreachable",
334
- data: { detail }
335
- }
336
- };
337
- stdio.send(errorResponse).catch(() => {
338
- });
452
+ const handle = pendingRequests.get(requestId);
453
+ if (handle !== void 0) {
454
+ pendingRequests.delete(requestId);
455
+ clearTimeout(handle);
456
+ void sendErrorResponse(requestId, "Tandem HTTP upstream unreachable", detail);
457
+ }
339
458
  }
340
459
  });
460
+ }
461
+ async function synthesizeBuffered(message, detail) {
462
+ const buffered2 = preReadyBuffer.splice(0);
463
+ const ids = buffered2.map((msg) => getRequestId(msg)).filter((id) => id !== void 0);
464
+ for (const id of ids) {
465
+ await sendErrorResponse(id, message, detail);
466
+ }
467
+ }
468
+ async function synthesizePending(message, detail) {
469
+ if (pendingRequests.size === 0) return;
470
+ const ids = [...pendingRequests.keys()];
471
+ for (const handle of pendingRequests.values()) clearTimeout(handle);
472
+ pendingRequests.clear();
473
+ await Promise.all(ids.map((id) => sendErrorResponse(id, message, detail)));
474
+ }
475
+ const shutdown = async (code = 0, synth) => {
476
+ if (!shuttingDown) {
477
+ shuttingDown = true;
478
+ setTimeout(() => process.exit(code), 2e3).unref();
479
+ for (const handle of pendingRequests.values()) clearTimeout(handle);
480
+ if (synth) {
481
+ await synthesizeBuffered(synth.message, synth.detail);
482
+ await synthesizePending(synth.message, synth.detail);
483
+ }
484
+ await http.close().catch((err) => {
485
+ const detail = err instanceof Error ? err.message : String(err);
486
+ process.stderr.write(`[tandem mcp-stdio] http.close failed: ${detail}
487
+ `);
488
+ });
489
+ await stdio.close().catch((err) => {
490
+ const detail = err instanceof Error ? err.message : String(err);
491
+ process.stderr.write(`[tandem mcp-stdio] stdio.close failed: ${detail}
492
+ `);
493
+ });
494
+ }
495
+ process.exit(code);
496
+ };
497
+ function deferredShutdown(synth) {
498
+ setTimeout(() => void shutdown(1, synth), PREFLIGHT_GRACE_MS);
499
+ }
500
+ stdio.onmessage = (msg) => {
501
+ if (!httpReady) {
502
+ preReadyBuffer.push(msg);
503
+ return;
504
+ }
505
+ forwardToUpstream(msg);
341
506
  };
342
507
  http.onmessage = (msg) => {
343
- stdio.send(msg).catch((err) => {
508
+ if (shuttingDown) return;
509
+ const responseId = getResponseId(msg);
510
+ if (responseId !== void 0) {
511
+ const handle = pendingRequests.get(responseId);
512
+ if (handle !== void 0) {
513
+ clearTimeout(handle);
514
+ pendingRequests.delete(responseId);
515
+ }
516
+ }
517
+ const sendHandler = (err) => {
344
518
  const detail = err instanceof Error ? err.message : String(err);
345
- process.stderr.write(`[tandem mcp-stdio] stdio write failed: ${detail}
346
- `);
347
- });
519
+ process.stderr.write(
520
+ `[tandem mcp-stdio] stdio write failed for id ${responseId ?? "<notification>"}: ${detail}
521
+ `
522
+ );
523
+ if (responseId !== void 0) {
524
+ void sendErrorResponse(responseId, "Tandem stdio write failed", detail);
525
+ }
526
+ void shutdown(1, {
527
+ message: "Tandem stdio write failed",
528
+ detail
529
+ });
530
+ };
531
+ try {
532
+ stdio.send(msg).catch(sendHandler);
533
+ } catch (err) {
534
+ sendHandler(err);
535
+ }
348
536
  };
349
537
  stdio.onerror = (err) => {
350
538
  process.stderr.write(`[tandem mcp-stdio] stdio error: ${err.message}
539
+ ${err.stack ?? ""}
351
540
  `);
352
541
  };
353
542
  http.onerror = (err) => {
354
- process.stderr.write(`[tandem mcp-stdio] http error: ${err.message}
355
- `);
543
+ const cause = err.cause;
544
+ process.stderr.write(
545
+ `[tandem mcp-stdio] http error: ${err.message}
546
+ ${err.stack ?? ""}${cause !== void 0 ? `
547
+ cause: ${cause}` : ""}
548
+ `
549
+ );
356
550
  };
357
551
  stdio.onclose = () => {
358
552
  void shutdown(0);
359
553
  };
360
554
  http.onclose = () => {
361
- void shutdown(0);
555
+ if (shuttingDown) return;
556
+ void shutdown(1, {
557
+ message: "Tandem HTTP upstream closed unexpectedly",
558
+ detail: "upstream connection dropped mid-session"
559
+ });
362
560
  };
363
561
  await stdio.start();
562
+ process.stdin.once("end", () => {
563
+ void shutdown(0);
564
+ });
565
+ const probe = await probeTandemServer({ url: baseUrl });
566
+ if (!probe.ok) {
567
+ const guidance = probe.kind === "unreachable" ? "Start the Tauri app or run `tandem start` on the host, then retry." : "The Tandem server is running but unhealthy \u2014 check the host logs.";
568
+ process.stderr.write(
569
+ `[tandem mcp-stdio] Tandem server preflight failed at ${probe.url} (${probe.reason}).
570
+ [tandem mcp-stdio] ${guidance}
571
+ `
572
+ );
573
+ const synthMessage = probe.kind === "unreachable" ? "Tandem server not running. Start the Tauri app or run `tandem start`." : "Tandem server unhealthy (check host logs).";
574
+ deferredShutdown({ message: synthMessage, detail: probe.reason });
575
+ return;
576
+ }
364
577
  try {
365
578
  await http.start();
366
579
  } catch (err) {
367
580
  const detail = err instanceof Error ? err.message : String(err);
368
581
  process.stderr.write(`[tandem mcp-stdio] upstream http start failed: ${detail}
369
582
  `);
370
- await shutdown(1);
583
+ deferredShutdown({ message: "Tandem HTTP upstream failed to start", detail });
584
+ return;
371
585
  }
586
+ httpReady = true;
587
+ const buffered = preReadyBuffer.splice(0);
588
+ for (const msg of buffered) forwardToUpstream(msg);
372
589
  }
373
590
  function getRequestId(msg) {
374
591
  const m = msg;
@@ -376,12 +593,37 @@ function getRequestId(msg) {
376
593
  if (typeof m.id === "string" || typeof m.id === "number") return m.id;
377
594
  return void 0;
378
595
  }
596
+ function getResponseId(msg) {
597
+ const m = msg;
598
+ if (typeof m.method === "string") return void 0;
599
+ if (typeof m.id === "string" || typeof m.id === "number") return m.id;
600
+ return void 0;
601
+ }
602
+ var PREFLIGHT_GRACE_MS, MAX_TIMEOUT_MS, STDIO_REQUEST_TIMEOUT_MS, VALID_TOKEN_RE2;
379
603
  var init_mcp_stdio = __esm({
380
604
  "src/cli/mcp-stdio.ts"() {
381
605
  "use strict";
382
606
  init_cli_runtime();
383
607
  init_preflight();
384
608
  redirectConsoleToStderr();
609
+ PREFLIGHT_GRACE_MS = 1500;
610
+ MAX_TIMEOUT_MS = 2147483647;
611
+ STDIO_REQUEST_TIMEOUT_MS = parseTimeoutMs(process.env.TANDEM_REQUEST_TIMEOUT_MS);
612
+ process.once("uncaughtException", (err) => {
613
+ process.stderr.write(
614
+ `[tandem mcp-stdio] uncaughtException: ${err.message}
615
+ ${err.stack ?? ""}
616
+ `
617
+ );
618
+ process.exit(1);
619
+ });
620
+ process.once("unhandledRejection", (reason) => {
621
+ const detail = reason instanceof Error ? reason.message : String(reason);
622
+ process.stderr.write(`[tandem mcp-stdio] unhandledRejection: ${detail}
623
+ `);
624
+ process.exit(1);
625
+ });
626
+ VALID_TOKEN_RE2 = /^[A-Za-z0-9_\-]{32,}$/;
385
627
  }
386
628
  });
387
629
 
@@ -513,7 +755,7 @@ async function startEventBridge(mcp, tandemUrl) {
513
755
  if (retries >= CHANNEL_MAX_RETRIES) {
514
756
  console.error("[Channel] SSE connection exhausted, reporting error and exiting");
515
757
  try {
516
- await fetch(`${tandemUrl}/api/channel-error`, {
758
+ await authFetch(`${tandemUrl}/api/channel-error`, {
517
759
  method: "POST",
518
760
  headers: { "Content-Type": "application/json" },
519
761
  body: JSON.stringify({
@@ -536,7 +778,7 @@ async function startEventBridge(mcp, tandemUrl) {
536
778
  async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
537
779
  const headers = { Accept: "text/event-stream" };
538
780
  if (lastEventId) headers["Last-Event-ID"] = lastEventId;
539
- const res = await fetch(`${tandemUrl}/api/events`, { headers });
781
+ const res = await authFetch(`${tandemUrl}/api/events`, { headers });
540
782
  if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
541
783
  if (!res.body) throw new Error("SSE endpoint returned no body");
542
784
  const reader = res.body.getReader();
@@ -547,7 +789,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
547
789
  let pendingAwareness = null;
548
790
  const AWARENESS_CLEAR_MS = 3e3;
549
791
  function clearAwareness(documentId) {
550
- fetch(`${tandemUrl}/api/channel-awareness`, {
792
+ authFetch(`${tandemUrl}/api/channel-awareness`, {
551
793
  method: "POST",
552
794
  headers: { "Content-Type": "application/json" },
553
795
  body: JSON.stringify({
@@ -562,7 +804,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
562
804
  if (!pendingAwareness) return;
563
805
  const event = pendingAwareness;
564
806
  pendingAwareness = null;
565
- fetch(`${tandemUrl}/api/channel-awareness`, {
807
+ authFetch(`${tandemUrl}/api/channel-awareness`, {
566
808
  method: "POST",
567
809
  headers: { "Content-Type": "application/json" },
568
810
  body: JSON.stringify({
@@ -637,7 +879,7 @@ async function getCachedMode(tandemUrl) {
637
879
  const now = Date.now();
638
880
  if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
639
881
  try {
640
- const res = await fetch(`${tandemUrl}/api/mode`);
882
+ const res = await authFetch(`${tandemUrl}/api/mode`);
641
883
  if (res.ok) {
642
884
  const { mode } = await res.json();
643
885
  cachedMode = mode;
@@ -659,6 +901,7 @@ var init_event_bridge = __esm({
659
901
  "src/channel/event-bridge.ts"() {
660
902
  "use strict";
661
903
  init_types();
904
+ init_cli_runtime();
662
905
  init_constants();
663
906
  AWARENESS_DEBOUNCE_MS = 500;
664
907
  MODE_CACHE_TTL_MS = 2e3;
@@ -726,7 +969,7 @@ async function runChannel(opts = {}) {
726
969
  if (req.params.name === "tandem_reply") {
727
970
  const args2 = req.params.arguments;
728
971
  try {
729
- const res = await fetch(`${tandemUrl}/api/channel-reply`, {
972
+ const res = await authFetch(`${tandemUrl}/api/channel-reply`, {
730
973
  method: "POST",
731
974
  headers: { "Content-Type": "application/json" },
732
975
  body: JSON.stringify(args2)
@@ -774,7 +1017,7 @@ async function runChannel(opts = {}) {
774
1017
  });
775
1018
  mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
776
1019
  try {
777
- const res = await fetch(`${tandemUrl}/api/channel-permission`, {
1020
+ const res = await authFetch(`${tandemUrl}/api/channel-permission`, {
778
1021
  method: "POST",
779
1022
  headers: { "Content-Type": "application/json" },
780
1023
  body: JSON.stringify({
@@ -863,6 +1106,163 @@ var init_channel = __esm({
863
1106
  }
864
1107
  });
865
1108
 
1109
+ // src/server/auth/token-store.ts
1110
+ import envPaths from "env-paths";
1111
+ import fs from "fs";
1112
+ import path from "path";
1113
+ function getTokenFilePath() {
1114
+ return path.join(envPaths("tandem", { suffix: "" }).data, TOKEN_FILE_NAME);
1115
+ }
1116
+ async function readTokenFromFile() {
1117
+ const filePath = getTokenFilePath();
1118
+ try {
1119
+ const content = await fs.promises.readFile(filePath, "utf8");
1120
+ if (process.platform !== "win32") {
1121
+ try {
1122
+ const stat = await fs.promises.stat(filePath);
1123
+ if ((stat.mode & 63) !== 0) {
1124
+ console.error("[tandem] auth token file has insecure permissions; attempting chmod 0600");
1125
+ await fs.promises.chmod(filePath, 384);
1126
+ }
1127
+ } catch {
1128
+ }
1129
+ }
1130
+ const trimmed = content.trim();
1131
+ return trimmed.length > 0 ? trimmed : null;
1132
+ } catch (err) {
1133
+ if (err.code === "ENOENT") return null;
1134
+ throw err;
1135
+ }
1136
+ }
1137
+ var init_token_store = __esm({
1138
+ "src/server/auth/token-store.ts"() {
1139
+ "use strict";
1140
+ init_constants();
1141
+ }
1142
+ });
1143
+
1144
+ // src/cli/rotate-token.ts
1145
+ var rotate_token_exports = {};
1146
+ __export(rotate_token_exports, {
1147
+ rotateToken: () => rotateToken
1148
+ });
1149
+ import { createHash, randomBytes } from "crypto";
1150
+ import { promises as fsPromises } from "fs";
1151
+ import path2 from "path";
1152
+ function fingerprint(token) {
1153
+ return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 8);
1154
+ }
1155
+ function generateToken() {
1156
+ return randomBytes(32).toString("base64url");
1157
+ }
1158
+ async function rotateToken() {
1159
+ console.error("\n[tandem] Rotating auth token...\n");
1160
+ if (process.env.TANDEM_AUTH_TOKEN) {
1161
+ console.error(
1162
+ "[tandem] Error: TANDEM_AUTH_TOKEN is set in the environment.\n Token rotation is not supported in env-token mode (used by Tauri).\n Unset the variable and let Tandem manage the token file, or rotate\n via your Tauri app's token management instead."
1163
+ );
1164
+ process.exit(1);
1165
+ }
1166
+ const oldToken = await readTokenFromFile();
1167
+ if (!oldToken) {
1168
+ console.error(
1169
+ "[tandem] Error: no token file found. Run `tandem setup` first to initialize the token."
1170
+ );
1171
+ process.exit(1);
1172
+ }
1173
+ const newToken = generateToken();
1174
+ const tokenPath = getTokenFilePath();
1175
+ const dir = path2.dirname(tokenPath);
1176
+ const tmpPath = path2.join(dir, `.auth-token-tmp-${randomBytes(4).toString("hex")}`);
1177
+ try {
1178
+ await fsPromises.writeFile(tmpPath, newToken, { encoding: "utf8", mode: 384 });
1179
+ await fsPromises.rename(tmpPath, tokenPath);
1180
+ } catch (err) {
1181
+ await fsPromises.unlink(tmpPath).catch(() => {
1182
+ });
1183
+ throw err;
1184
+ }
1185
+ const serverUrl = `http://localhost:${DEFAULT_MCP_PORT}`;
1186
+ let graceWindowActive = false;
1187
+ let serverRejected = false;
1188
+ let serverRejectedStatus = 0;
1189
+ try {
1190
+ const resp = await fetch(`${serverUrl}/api/rotate-token`, {
1191
+ method: "POST",
1192
+ headers: {
1193
+ "Content-Type": "application/json",
1194
+ Authorization: `Bearer ${oldToken}`
1195
+ },
1196
+ body: JSON.stringify({}),
1197
+ signal: AbortSignal.timeout(5e3)
1198
+ });
1199
+ if (resp.ok) {
1200
+ graceWindowActive = true;
1201
+ } else {
1202
+ serverRejected = true;
1203
+ serverRejectedStatus = resp.status;
1204
+ }
1205
+ } catch {
1206
+ console.error(
1207
+ "[tandem] Warning: server is not reachable. The new token is written to disk.\n Restart the server to activate the grace window; reconnect Claude Code after."
1208
+ );
1209
+ }
1210
+ let updatedCount = 0;
1211
+ let configErrors = [];
1212
+ try {
1213
+ const result = await applyConfigWithToken(newToken);
1214
+ updatedCount = result.updated;
1215
+ configErrors = result.errors;
1216
+ } catch (err) {
1217
+ console.error(
1218
+ `[tandem] Warning: failed to update MCP configs: ${err instanceof Error ? err.message : String(err)}`
1219
+ );
1220
+ }
1221
+ if (serverRejected) {
1222
+ console.error(
1223
+ `[tandem] WARNING: server rejected the rotation request (status: ${serverRejectedStatus}).`
1224
+ );
1225
+ if (updatedCount > 0) {
1226
+ console.error(
1227
+ ` ${updatedCount} config file(s) updated to the new token, but the server still
1228
+ holds the old token. Restart the server to complete rotation.`
1229
+ );
1230
+ }
1231
+ console.error(` Old fingerprint: ${fingerprint(oldToken)}`);
1232
+ console.error(` New fingerprint: ${fingerprint(newToken)}`);
1233
+ for (const e of configErrors) {
1234
+ console.error(` Warning: could not update config \u2014 ${e}`);
1235
+ }
1236
+ console.error("");
1237
+ return;
1238
+ }
1239
+ console.error("[tandem] Rotated auth token.");
1240
+ console.error(` Old fingerprint: ${fingerprint(oldToken)}`);
1241
+ console.error(` New fingerprint: ${fingerprint(newToken)}`);
1242
+ console.error(` Updated ${updatedCount} config file(s).`);
1243
+ for (const e of configErrors) {
1244
+ console.error(` Warning: could not update config \u2014 ${e}`);
1245
+ }
1246
+ if (graceWindowActive) {
1247
+ console.error(
1248
+ " Old token remains valid for 60 seconds; reconnect Claude Code within that window."
1249
+ );
1250
+ } else {
1251
+ console.error(
1252
+ " Server was not running \u2014 start it with `tandem` and reconnect Claude Code with the new token."
1253
+ );
1254
+ }
1255
+ console.error("");
1256
+ }
1257
+ var init_rotate_token = __esm({
1258
+ "src/cli/rotate-token.ts"() {
1259
+ "use strict";
1260
+ init_token_store();
1261
+ init_constants();
1262
+ init_setup();
1263
+ }
1264
+ });
1265
+
866
1266
  // src/cli/start.ts
867
1267
  var start_exports = {};
868
1268
  __export(start_exports, {
@@ -905,7 +1305,22 @@ var init_start = __esm({
905
1305
 
906
1306
  // src/cli/index.ts
907
1307
  import updateNotifier from "update-notifier";
908
- var version = true ? "0.6.2" : "0.0.0-dev";
1308
+ process.once("uncaughtException", (err) => {
1309
+ const msg = err instanceof Error ? err.stack ?? err.message : String(err);
1310
+ try {
1311
+ process.stderr.write(`[tandem cli] uncaughtException: ${msg}
1312
+ `);
1313
+ } catch {
1314
+ }
1315
+ process.exit(1);
1316
+ });
1317
+ process.once("unhandledRejection", (reason) => {
1318
+ const detail = reason instanceof Error ? reason.message : String(reason);
1319
+ process.stderr.write(`[tandem cli] unhandledRejection: ${detail}
1320
+ `);
1321
+ process.exit(1);
1322
+ });
1323
+ var version = true ? "0.7.0" : "0.0.0-dev";
909
1324
  var args = process.argv.slice(2);
910
1325
  var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
911
1326
  if (!isStdioMode) {
@@ -919,6 +1334,7 @@ Usage:
919
1334
  tandem setup Register MCP tools with Claude Code / Claude Desktop
920
1335
  tandem setup --force Register to default paths regardless of detection
921
1336
  tandem setup --with-channel-shim Also register the stdio channel shim (legacy opt-in)
1337
+ tandem rotate-token Rotate the auth token with a 60-second grace window
922
1338
  tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP
923
1339
  (used by the plugin's Cowork bridge; requires
924
1340
  tandem server running on the host)
@@ -946,6 +1362,9 @@ try {
946
1362
  } else if (args[0] === "channel") {
947
1363
  const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
948
1364
  await runChannelCli2();
1365
+ } else if (args[0] === "rotate-token") {
1366
+ const { rotateToken: rotateToken2 } = await Promise.resolve().then(() => (init_rotate_token(), rotate_token_exports));
1367
+ await rotateToken2();
949
1368
  } else if (!args[0] || args[0] === "start") {
950
1369
  const { runStart: runStart2 } = await Promise.resolve().then(() => (init_start(), start_exports));
951
1370
  runStart2();