tandem-editor 0.6.3 → 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,10 +275,30 @@ 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
 
@@ -310,15 +356,52 @@ var mcp_stdio_exports = {};
310
356
  __export(mcp_stdio_exports, {
311
357
  getRequestId: () => getRequestId,
312
358
  getResponseId: () => getResponseId,
359
+ parseTimeoutMs: () => parseTimeoutMs,
360
+ readAndValidateAuthToken: () => readAndValidateAuthToken,
313
361
  runMcpStdio: () => runMcpStdio
314
362
  });
315
363
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
316
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
+ }
317
397
  async function runMcpStdio() {
318
398
  const baseUrl = resolveTandemUrl();
319
- 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
+ });
320
403
  const stdio = new StdioServerTransport();
321
- const pendingIds = /* @__PURE__ */ new Set();
404
+ const pendingRequests = /* @__PURE__ */ new Map();
322
405
  const preReadyBuffer = [];
323
406
  let shuttingDown = false;
324
407
  let httpReady = false;
@@ -346,14 +429,32 @@ async function runMcpStdio() {
346
429
  }
347
430
  }
348
431
  function forwardToUpstream(msg) {
432
+ if (shuttingDown) return;
349
433
  const requestId = getRequestId(msg);
350
- if (requestId !== void 0) pendingIds.add(requestId);
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);
446
+ }
351
447
  http.send(msg).catch((err) => {
352
448
  const detail = err instanceof Error ? err.message : String(err);
353
449
  process.stderr.write(`[tandem mcp-stdio] upstream send failed: ${detail}
354
450
  `);
355
- if (requestId !== void 0 && pendingIds.delete(requestId)) {
356
- void sendErrorResponse(requestId, "Tandem HTTP upstream unreachable", detail);
451
+ if (requestId !== void 0) {
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
+ }
357
458
  }
358
459
  });
359
460
  }
@@ -365,14 +466,17 @@ async function runMcpStdio() {
365
466
  }
366
467
  }
367
468
  async function synthesizePending(message, detail) {
368
- if (pendingIds.size === 0) return;
369
- const ids = [...pendingIds];
370
- pendingIds.clear();
469
+ if (pendingRequests.size === 0) return;
470
+ const ids = [...pendingRequests.keys()];
471
+ for (const handle of pendingRequests.values()) clearTimeout(handle);
472
+ pendingRequests.clear();
371
473
  await Promise.all(ids.map((id) => sendErrorResponse(id, message, detail)));
372
474
  }
373
475
  const shutdown = async (code = 0, synth) => {
374
476
  if (!shuttingDown) {
375
477
  shuttingDown = true;
478
+ setTimeout(() => process.exit(code), 2e3).unref();
479
+ for (const handle of pendingRequests.values()) clearTimeout(handle);
376
480
  if (synth) {
377
481
  await synthesizeBuffered(synth.message, synth.detail);
378
482
  await synthesizePending(synth.message, synth.detail);
@@ -401,23 +505,34 @@ async function runMcpStdio() {
401
505
  forwardToUpstream(msg);
402
506
  };
403
507
  http.onmessage = (msg) => {
508
+ if (shuttingDown) return;
404
509
  const responseId = getResponseId(msg);
405
- stdio.send(msg).then(
406
- () => {
407
- if (responseId !== void 0) pendingIds.delete(responseId);
408
- },
409
- (err) => {
410
- const detail = err instanceof Error ? err.message : String(err);
411
- process.stderr.write(
412
- `[tandem mcp-stdio] stdio write failed for id ${responseId ?? "<notification>"}: ${detail}
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) => {
518
+ const detail = err instanceof Error ? err.message : String(err);
519
+ process.stderr.write(
520
+ `[tandem mcp-stdio] stdio write failed for id ${responseId ?? "<notification>"}: ${detail}
413
521
  `
414
- );
415
- void shutdown(1, {
416
- message: "Tandem stdio write failed",
417
- detail
418
- });
522
+ );
523
+ if (responseId !== void 0) {
524
+ void sendErrorResponse(responseId, "Tandem stdio write failed", detail);
419
525
  }
420
- );
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
+ }
421
536
  };
422
537
  stdio.onerror = (err) => {
423
538
  process.stderr.write(`[tandem mcp-stdio] stdio error: ${err.message}
@@ -444,6 +559,9 @@ cause: ${cause}` : ""}
444
559
  });
445
560
  };
446
561
  await stdio.start();
562
+ process.stdin.once("end", () => {
563
+ void shutdown(0);
564
+ });
447
565
  const probe = await probeTandemServer({ url: baseUrl });
448
566
  if (!probe.ok) {
449
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.";
@@ -481,7 +599,7 @@ function getResponseId(msg) {
481
599
  if (typeof m.id === "string" || typeof m.id === "number") return m.id;
482
600
  return void 0;
483
601
  }
484
- var PREFLIGHT_GRACE_MS;
602
+ var PREFLIGHT_GRACE_MS, MAX_TIMEOUT_MS, STDIO_REQUEST_TIMEOUT_MS, VALID_TOKEN_RE2;
485
603
  var init_mcp_stdio = __esm({
486
604
  "src/cli/mcp-stdio.ts"() {
487
605
  "use strict";
@@ -489,6 +607,8 @@ var init_mcp_stdio = __esm({
489
607
  init_preflight();
490
608
  redirectConsoleToStderr();
491
609
  PREFLIGHT_GRACE_MS = 1500;
610
+ MAX_TIMEOUT_MS = 2147483647;
611
+ STDIO_REQUEST_TIMEOUT_MS = parseTimeoutMs(process.env.TANDEM_REQUEST_TIMEOUT_MS);
492
612
  process.once("uncaughtException", (err) => {
493
613
  process.stderr.write(
494
614
  `[tandem mcp-stdio] uncaughtException: ${err.message}
@@ -503,6 +623,7 @@ ${err.stack ?? ""}
503
623
  `);
504
624
  process.exit(1);
505
625
  });
626
+ VALID_TOKEN_RE2 = /^[A-Za-z0-9_\-]{32,}$/;
506
627
  }
507
628
  });
508
629
 
@@ -634,7 +755,7 @@ async function startEventBridge(mcp, tandemUrl) {
634
755
  if (retries >= CHANNEL_MAX_RETRIES) {
635
756
  console.error("[Channel] SSE connection exhausted, reporting error and exiting");
636
757
  try {
637
- await fetch(`${tandemUrl}/api/channel-error`, {
758
+ await authFetch(`${tandemUrl}/api/channel-error`, {
638
759
  method: "POST",
639
760
  headers: { "Content-Type": "application/json" },
640
761
  body: JSON.stringify({
@@ -657,7 +778,7 @@ async function startEventBridge(mcp, tandemUrl) {
657
778
  async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
658
779
  const headers = { Accept: "text/event-stream" };
659
780
  if (lastEventId) headers["Last-Event-ID"] = lastEventId;
660
- const res = await fetch(`${tandemUrl}/api/events`, { headers });
781
+ const res = await authFetch(`${tandemUrl}/api/events`, { headers });
661
782
  if (!res.ok) throw new Error(`SSE endpoint returned ${res.status}`);
662
783
  if (!res.body) throw new Error("SSE endpoint returned no body");
663
784
  const reader = res.body.getReader();
@@ -668,7 +789,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
668
789
  let pendingAwareness = null;
669
790
  const AWARENESS_CLEAR_MS = 3e3;
670
791
  function clearAwareness(documentId) {
671
- fetch(`${tandemUrl}/api/channel-awareness`, {
792
+ authFetch(`${tandemUrl}/api/channel-awareness`, {
672
793
  method: "POST",
673
794
  headers: { "Content-Type": "application/json" },
674
795
  body: JSON.stringify({
@@ -683,7 +804,7 @@ async function connectAndStream(mcp, tandemUrl, lastEventId, onEventId) {
683
804
  if (!pendingAwareness) return;
684
805
  const event = pendingAwareness;
685
806
  pendingAwareness = null;
686
- fetch(`${tandemUrl}/api/channel-awareness`, {
807
+ authFetch(`${tandemUrl}/api/channel-awareness`, {
687
808
  method: "POST",
688
809
  headers: { "Content-Type": "application/json" },
689
810
  body: JSON.stringify({
@@ -758,7 +879,7 @@ async function getCachedMode(tandemUrl) {
758
879
  const now = Date.now();
759
880
  if (now - cachedModeAt < MODE_CACHE_TTL_MS) return cachedMode;
760
881
  try {
761
- const res = await fetch(`${tandemUrl}/api/mode`);
882
+ const res = await authFetch(`${tandemUrl}/api/mode`);
762
883
  if (res.ok) {
763
884
  const { mode } = await res.json();
764
885
  cachedMode = mode;
@@ -780,6 +901,7 @@ var init_event_bridge = __esm({
780
901
  "src/channel/event-bridge.ts"() {
781
902
  "use strict";
782
903
  init_types();
904
+ init_cli_runtime();
783
905
  init_constants();
784
906
  AWARENESS_DEBOUNCE_MS = 500;
785
907
  MODE_CACHE_TTL_MS = 2e3;
@@ -847,7 +969,7 @@ async function runChannel(opts = {}) {
847
969
  if (req.params.name === "tandem_reply") {
848
970
  const args2 = req.params.arguments;
849
971
  try {
850
- const res = await fetch(`${tandemUrl}/api/channel-reply`, {
972
+ const res = await authFetch(`${tandemUrl}/api/channel-reply`, {
851
973
  method: "POST",
852
974
  headers: { "Content-Type": "application/json" },
853
975
  body: JSON.stringify(args2)
@@ -895,7 +1017,7 @@ async function runChannel(opts = {}) {
895
1017
  });
896
1018
  mcp.setNotificationHandler(PermissionRequestSchema, async ({ params }) => {
897
1019
  try {
898
- const res = await fetch(`${tandemUrl}/api/channel-permission`, {
1020
+ const res = await authFetch(`${tandemUrl}/api/channel-permission`, {
899
1021
  method: "POST",
900
1022
  headers: { "Content-Type": "application/json" },
901
1023
  body: JSON.stringify({
@@ -984,6 +1106,163 @@ var init_channel = __esm({
984
1106
  }
985
1107
  });
986
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
+
987
1266
  // src/cli/start.ts
988
1267
  var start_exports = {};
989
1268
  __export(start_exports, {
@@ -1026,7 +1305,22 @@ var init_start = __esm({
1026
1305
 
1027
1306
  // src/cli/index.ts
1028
1307
  import updateNotifier from "update-notifier";
1029
- var version = true ? "0.6.3" : "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";
1030
1324
  var args = process.argv.slice(2);
1031
1325
  var isStdioMode = args[0] === "mcp-stdio" || args[0] === "channel";
1032
1326
  if (!isStdioMode) {
@@ -1040,6 +1334,7 @@ Usage:
1040
1334
  tandem setup Register MCP tools with Claude Code / Claude Desktop
1041
1335
  tandem setup --force Register to default paths regardless of detection
1042
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
1043
1338
  tandem mcp-stdio Run as a stdio MCP server proxying to local HTTP
1044
1339
  (used by the plugin's Cowork bridge; requires
1045
1340
  tandem server running on the host)
@@ -1067,6 +1362,9 @@ try {
1067
1362
  } else if (args[0] === "channel") {
1068
1363
  const { runChannelCli: runChannelCli2 } = await Promise.resolve().then(() => (init_channel(), channel_exports));
1069
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();
1070
1368
  } else if (!args[0] || args[0] === "start") {
1071
1369
  const { runStart: runStart2 } = await Promise.resolve().then(() => (init_start(), start_exports));
1072
1370
  runStart2();