claude-relay 2.2.4 → 2.3.1

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/README.md CHANGED
@@ -133,6 +133,7 @@ Scan the QR code with your phone to connect instantly, or open the URL displayed
133
133
  * **Send While Processing** - Queue messages without waiting for the current response to finish.
134
134
  * **Sticky Todo Overlay** - TodoWrite tasks float as a progress bar while you scroll through the conversation.
135
135
  * **Scroll Position Hold** - Reading earlier messages will not get interrupted by new content arriving.
136
+ * **RTL Text Support** - Automatic bidirectional text rendering for Arabic, Hebrew, and other RTL languages.
136
137
 
137
138
  **Server and Security**
138
139
 
@@ -182,6 +183,9 @@ If push registration fails: check whether your browser trusts HTTPS and whether
182
183
  ```bash
183
184
  npx claude-relay # Default (port 2633)
184
185
  npx claude-relay -p 8080 # Specify port
186
+ npx claude-relay -y # Skip interactive prompts (accept defaults)
187
+ npx claude-relay -y --pin 123456
188
+ # Non-interactive with PIN (for scripts/CI)
185
189
  npx claude-relay --no-https # Disable HTTPS
186
190
  npx claude-relay --no-update # Skip update check
187
191
  npx claude-relay --debug # Enable debug panel
@@ -189,6 +193,9 @@ npx claude-relay --add . # Add current directory to running daemon
189
193
  npx claude-relay --add /path # Add a project by path
190
194
  npx claude-relay --remove . # Remove a project
191
195
  npx claude-relay --list # List registered projects
196
+ npx claude-relay --shutdown # Stop the running daemon
197
+ npx claude-relay --dangerously-skip-permissions
198
+ # Bypass all permission prompts (PIN required during setup)
192
199
  ```
193
200
 
194
201
  ## Requirements
@@ -236,6 +243,12 @@ For a detailed sequence diagram, daemon structure, and design decisions, refer t
236
243
 
237
244
  ---
238
245
 
246
+ ## Contributors
247
+
248
+ <a href="https://github.com/chadbyte/claude-relay/graphs/contributors">
249
+ <img src="https://contrib.rocks/image?repo=chadbyte/claude-relay" />
250
+ </a>
251
+
239
252
  ## Contributing
240
253
 
241
254
  Bug fixes and typos are welcome. For feature suggestions, please open an issue first:
package/bin/cli.js CHANGED
@@ -6,7 +6,14 @@ var path = require("path");
6
6
  var { execSync, execFileSync, spawn } = require("child_process");
7
7
  var qrcode = require("qrcode-terminal");
8
8
  var net = require("net");
9
- var { loadConfig, saveConfig, configPath, socketPath, logPath, ensureConfigDir, isDaemonAlive, isDaemonAliveAsync, generateSlug, clearStaleConfig, loadClayrc, saveClayrc } = require("../lib/config");
9
+
10
+ // Detect dev mode before loading config (env must be set before require)
11
+ var _isDev = process.argv[1] && path.basename(process.argv[1]) === "claude-relay-dev";
12
+ if (_isDev) {
13
+ process.env.CLAUDE_RELAY_HOME = path.join(os.homedir(), ".claude-relay-dev");
14
+ }
15
+
16
+ var { loadConfig, saveConfig, configPath, socketPath, logPath, ensureConfigDir, isDaemonAlive, isDaemonAliveAsync, generateSlug, clearStaleConfig, loadClayrc, saveClayrc, readCrashInfo } = require("../lib/config");
10
17
  var { sendIPCCommand } = require("../lib/ipc");
11
18
  var { generateAuthToken } = require("../lib/server");
12
19
 
@@ -22,7 +29,7 @@ function openUrl(url) {
22
29
  }
23
30
 
24
31
  var args = process.argv.slice(2);
25
- var port = 2633;
32
+ var port = _isDev ? 2635 : 2633;
26
33
  var useHttps = true;
27
34
  var skipUpdate = false;
28
35
  var debugMode = false;
@@ -32,6 +39,7 @@ var shutdownMode = false;
32
39
  var addPath = null;
33
40
  var removePath = null;
34
41
  var listMode = false;
42
+ var dangerouslySkipPermissions = false;
35
43
 
36
44
  for (var i = 0; i < args.length; i++) {
37
45
  if (args[i] === "-p" || args[i] === "--port") {
@@ -62,6 +70,8 @@ for (var i = 0; i < args.length; i++) {
62
70
  i++;
63
71
  } else if (args[i] === "--list") {
64
72
  listMode = true;
73
+ } else if (args[i] === "--dangerously-skip-permissions") {
74
+ dangerouslySkipPermissions = true;
65
75
  } else if (args[i] === "-h" || args[i] === "--help") {
66
76
  console.log("Usage: claude-relay [-p|--port <port>] [--no-https] [--no-update] [--debug] [-y|--yes] [--pin <pin>] [--shutdown]");
67
77
  console.log(" claude-relay --add <path> Add a project to the running daemon");
@@ -79,6 +89,8 @@ for (var i = 0; i < args.length; i++) {
79
89
  console.log(" --add <path> Add a project directory (use '.' for current)");
80
90
  console.log(" --remove <path> Remove a project directory");
81
91
  console.log(" --list List all registered projects");
92
+ console.log(" --dangerously-skip-permissions");
93
+ console.log(" Bypass all permission prompts (requires --pin)");
82
94
  process.exit(0);
83
95
  }
84
96
  }
@@ -270,6 +282,10 @@ function stopDaemonWatcher() {
270
282
  }
271
283
  }
272
284
 
285
+ var _restartAttempts = 0;
286
+ var MAX_RESTART_ATTEMPTS = 5;
287
+ var _restartBackoffStart = 0;
288
+
273
289
  function onDaemonDied() {
274
290
  stopDaemonWatcher();
275
291
  // Clean up stdin in case a prompt is active
@@ -278,11 +294,117 @@ function onDaemonDied() {
278
294
  process.stdin.pause();
279
295
  process.stdin.removeAllListeners("data");
280
296
  } catch (e) {}
297
+
298
+ // Check if this was a crash (crash.json exists) vs intentional shutdown
299
+ var crashInfo = readCrashInfo();
300
+ if (!crashInfo) {
301
+ // Intentional shutdown, no restart
302
+ log("");
303
+ log(sym.warn + " " + a.yellow + "Server has been shut down." + a.reset);
304
+ log(a.dim + " Run " + a.reset + "npx claude-relay" + a.dim + " to start again." + a.reset);
305
+ log("");
306
+ process.exit(0);
307
+ return;
308
+ }
309
+
310
+ // Reset backoff counter if enough time has passed since last restart burst
311
+ var now = Date.now();
312
+ if (_restartBackoffStart && now - _restartBackoffStart > 60000) {
313
+ _restartAttempts = 0;
314
+ }
315
+
316
+ _restartAttempts++;
317
+ if (_restartAttempts > MAX_RESTART_ATTEMPTS) {
318
+ log("");
319
+ log(sym.warn + " " + a.red + "Server crashed too many times (" + MAX_RESTART_ATTEMPTS + " attempts). Giving up." + a.reset);
320
+ if (crashInfo.reason) {
321
+ log(a.dim + " " + crashInfo.reason.split("\n")[0] + a.reset);
322
+ }
323
+ log(a.dim + " Check logs: " + a.reset + logPath());
324
+ log("");
325
+ process.exit(1);
326
+ return;
327
+ }
328
+
329
+ if (_restartAttempts === 1) _restartBackoffStart = now;
330
+
281
331
  log("");
282
- log(sym.warn + " " + a.yellow + "Server has been shut down." + a.reset);
283
- log(a.dim + " Run " + a.reset + "npx claude-relay" + a.dim + " to start again." + a.reset);
332
+ log(sym.warn + " " + a.yellow + "Server crashed. Restarting... (" + _restartAttempts + "/" + MAX_RESTART_ATTEMPTS + ")" + a.reset);
333
+ if (crashInfo.reason) {
334
+ log(a.dim + " " + crashInfo.reason.split("\n")[0] + a.reset);
335
+ }
336
+
337
+ // Re-fork the daemon from saved config
338
+ restartDaemonFromConfig();
339
+ }
340
+
341
+ async function restartDaemonFromConfig() {
342
+ var lastConfig = loadConfig();
343
+ if (!lastConfig || !lastConfig.projects) {
344
+ log(a.red + " No config found. Cannot restart." + a.reset);
345
+ process.exit(1);
346
+ return;
347
+ }
348
+
349
+ clearStaleConfig();
350
+
351
+ // Wait for port to be released
352
+ var targetPort = lastConfig.port || port;
353
+ var waited = 0;
354
+ while (waited < 3000) {
355
+ var free = await isPortFree(targetPort);
356
+ if (free) break;
357
+ await new Promise(function (resolve) { setTimeout(resolve, 300); });
358
+ waited += 300;
359
+ }
360
+
361
+ // Rebuild config (preserve everything except pid)
362
+ var newConfig = {
363
+ pid: null,
364
+ port: targetPort,
365
+ pinHash: lastConfig.pinHash || null,
366
+ tls: lastConfig.tls !== undefined ? lastConfig.tls : useHttps,
367
+ debug: lastConfig.debug || false,
368
+ keepAwake: lastConfig.keepAwake || false,
369
+ dangerouslySkipPermissions: lastConfig.dangerouslySkipPermissions || false,
370
+ projects: (lastConfig.projects || []).filter(function (p) {
371
+ return fs.existsSync(p.path);
372
+ }),
373
+ };
374
+
375
+ ensureConfigDir();
376
+ saveConfig(newConfig);
377
+
378
+ var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
379
+ var logFile = logPath();
380
+ var logFd = fs.openSync(logFile, "a");
381
+
382
+ var child = spawn(process.execPath, [daemonScript], {
383
+ detached: true,
384
+ windowsHide: true,
385
+ stdio: ["ignore", logFd, logFd],
386
+ env: Object.assign({}, process.env, {
387
+ CLAUDE_RELAY_CONFIG: configPath(),
388
+ }),
389
+ });
390
+ child.unref();
391
+ fs.closeSync(logFd);
392
+
393
+ newConfig.pid = child.pid;
394
+ saveConfig(newConfig);
395
+
396
+ // Wait and verify
397
+ await new Promise(function (resolve) { setTimeout(resolve, 1200); });
398
+ var alive = await isDaemonAliveAsync(newConfig);
399
+ if (!alive) {
400
+ log(a.red + " Restart failed. Check logs: " + a.reset + logFile);
401
+ process.exit(1);
402
+ return;
403
+ }
404
+ var ip = getLocalIP();
405
+ log(sym.done + " " + a.green + "Server restarted successfully." + a.reset);
284
406
  log("");
285
- process.exit(0);
407
+ showMainMenu(newConfig, ip);
286
408
  }
287
409
 
288
410
  // --- Network ---
@@ -352,7 +474,7 @@ function getAllIPs() {
352
474
 
353
475
  function ensureCerts(ip) {
354
476
  var homeDir = os.homedir();
355
- var certDir = path.join(homeDir, ".claude-relay", "certs");
477
+ var certDir = path.join(process.env.CLAUDE_RELAY_HOME || path.join(homeDir, ".claude-relay"), "certs");
356
478
  var keyPath = path.join(certDir, "key.pem");
357
479
  var certPath = path.join(certDir, "cert.pem");
358
480
 
@@ -1098,6 +1220,7 @@ async function forkDaemon(pin, keepAwake, extraProjects) {
1098
1220
  tls: hasTls,
1099
1221
  debug: debugMode,
1100
1222
  keepAwake: keepAwake,
1223
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
1101
1224
  projects: allProjects,
1102
1225
  };
1103
1226
 
@@ -1176,6 +1299,7 @@ async function restartDaemonWithTLS(config, callback) {
1176
1299
  tls: true,
1177
1300
  debug: config.debug || false,
1178
1301
  keepAwake: config.keepAwake || false,
1302
+ dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
1179
1303
  projects: config.projects || [],
1180
1304
  };
1181
1305
 
@@ -1938,8 +2062,16 @@ var currentVersion = require("../package.json").version;
1938
2062
  // No daemon running — first-time setup
1939
2063
  if (autoYes) {
1940
2064
  var pin = cliPin || null;
2065
+ if (dangerouslySkipPermissions && !pin) {
2066
+ console.error(" " + sym.warn + " " + a.red + "--dangerously-skip-permissions requires --pin <pin>" + a.reset);
2067
+ process.exit(1);
2068
+ return;
2069
+ }
1941
2070
  console.log(" " + sym.done + " Auto-accepted disclaimer");
1942
2071
  console.log(" " + sym.done + " PIN: " + (pin ? "Enabled" : "Skipped"));
2072
+ if (dangerouslySkipPermissions) {
2073
+ console.log(" " + sym.warn + " " + a.yellow + "Skip permissions mode enabled" + a.reset);
2074
+ }
1943
2075
  var autoRc = loadClayrc();
1944
2076
  var autoRestorable = (autoRc.recentProjects || []).filter(function (p) {
1945
2077
  return p.path !== cwd && fs.existsSync(p.path);
@@ -1950,6 +2082,12 @@ var currentVersion = require("../package.json").version;
1950
2082
  await forkDaemon(pin, false, autoRestorable.length > 0 ? autoRestorable : undefined);
1951
2083
  } else {
1952
2084
  setup(function (pin, keepAwake) {
2085
+ if (dangerouslySkipPermissions && !pin) {
2086
+ log(sym.warn + " " + a.red + "--dangerously-skip-permissions requires a PIN." + a.reset);
2087
+ log(a.dim + " Please set a PIN to use skip permissions mode." + a.reset);
2088
+ process.exit(1);
2089
+ return;
2090
+ }
1953
2091
  // Check ~/.clayrc for previous projects to restore
1954
2092
  var rc = loadClayrc();
1955
2093
  var restorable = (rc.recentProjects || []).filter(function (p) {
package/lib/config.js CHANGED
@@ -3,8 +3,9 @@ var path = require("path");
3
3
  var os = require("os");
4
4
  var net = require("net");
5
5
 
6
- var CONFIG_DIR = path.join(os.homedir(), ".claude-relay");
6
+ var CONFIG_DIR = process.env.CLAUDE_RELAY_HOME || path.join(os.homedir(), ".claude-relay");
7
7
  var CLAYRC_PATH = path.join(os.homedir(), ".clayrc");
8
+ var CRASH_INFO_PATH = path.join(CONFIG_DIR, "crash.json");
8
9
 
9
10
  function configPath() {
10
11
  return path.join(CONFIG_DIR, "daemon.json");
@@ -12,7 +13,8 @@ function configPath() {
12
13
 
13
14
  function socketPath() {
14
15
  if (process.platform === "win32") {
15
- return "\\\\.\\pipe\\claude-relay-daemon";
16
+ var pipeName = process.env.CLAUDE_RELAY_HOME ? "claude-relay-dev-daemon" : "claude-relay-daemon";
17
+ return "\\\\.\\pipe\\" + pipeName;
16
18
  }
17
19
  return path.join(CONFIG_DIR, "daemon.sock");
18
20
  }
@@ -105,6 +107,32 @@ function clearStaleConfig() {
105
107
  }
106
108
  }
107
109
 
110
+ // --- Crash info ---
111
+
112
+ function crashInfoPath() {
113
+ return CRASH_INFO_PATH;
114
+ }
115
+
116
+ function writeCrashInfo(info) {
117
+ try {
118
+ ensureConfigDir();
119
+ fs.writeFileSync(CRASH_INFO_PATH, JSON.stringify(info));
120
+ } catch (e) {}
121
+ }
122
+
123
+ function readCrashInfo() {
124
+ try {
125
+ var data = fs.readFileSync(CRASH_INFO_PATH, "utf8");
126
+ return JSON.parse(data);
127
+ } catch (e) {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function clearCrashInfo() {
133
+ try { fs.unlinkSync(CRASH_INFO_PATH); } catch (e) {}
134
+ }
135
+
108
136
  // --- ~/.clayrc (recent projects persistence) ---
109
137
 
110
138
  function clayrcPath() {
@@ -183,6 +211,10 @@ module.exports = {
183
211
  isDaemonAliveAsync: isDaemonAliveAsync,
184
212
  generateSlug: generateSlug,
185
213
  clearStaleConfig: clearStaleConfig,
214
+ crashInfoPath: crashInfoPath,
215
+ writeCrashInfo: writeCrashInfo,
216
+ readCrashInfo: readCrashInfo,
217
+ clearCrashInfo: clearCrashInfo,
186
218
  clayrcPath: clayrcPath,
187
219
  loadClayrc: loadClayrc,
188
220
  saveClayrc: saveClayrc,
package/lib/daemon.js CHANGED
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // Polyfill Symbol.dispose/asyncDispose for Node 18 (used by claude-agent-sdk)
4
+ if (!Symbol.dispose) Symbol.dispose = Symbol("Symbol.dispose");
5
+ if (!Symbol.asyncDispose) Symbol.asyncDispose = Symbol("Symbol.asyncDispose");
6
+
3
7
  var fs = require("fs");
4
8
  var path = require("path");
5
- var { loadConfig, saveConfig, socketPath, generateSlug, syncClayrc } = require("./config");
9
+ var { loadConfig, saveConfig, socketPath, generateSlug, syncClayrc, writeCrashInfo, readCrashInfo, clearCrashInfo } = require("./config");
6
10
  var { createIPCServer } = require("./ipc");
7
11
  var { createServer } = require("./server");
8
12
 
@@ -20,7 +24,7 @@ try {
20
24
  var tlsOptions = null;
21
25
  if (config.tls) {
22
26
  var os = require("os");
23
- var certDir = path.join(os.homedir(), ".claude-relay", "certs");
27
+ var certDir = path.join(process.env.CLAUDE_RELAY_HOME || path.join(os.homedir(), ".claude-relay"), "certs");
24
28
  var keyPath = path.join(certDir, "key.pem");
25
29
  var certPath = path.join(certDir, "cert.pem");
26
30
  try {
@@ -43,6 +47,23 @@ try {
43
47
  if (!fs.existsSync(caRoot)) caRoot = null;
44
48
  } catch (e) {}
45
49
 
50
+ // --- Resolve LAN IP for share URL ---
51
+ var os2 = require("os");
52
+ var lanIp = (function () {
53
+ var ifaces = os2.networkInterfaces();
54
+ for (var addrs of Object.values(ifaces)) {
55
+ for (var i = 0; i < addrs.length; i++) {
56
+ if (addrs[i].family === "IPv4" && !addrs[i].internal && addrs[i].address.startsWith("100.")) return addrs[i].address;
57
+ }
58
+ }
59
+ for (var addrs of Object.values(ifaces)) {
60
+ for (var i = 0; i < addrs.length; i++) {
61
+ if (addrs[i].family === "IPv4" && !addrs[i].internal) return addrs[i].address;
62
+ }
63
+ }
64
+ return null;
65
+ })();
66
+
46
67
  // --- Create multi-project server ---
47
68
  var relay = createServer({
48
69
  tlsOptions: tlsOptions,
@@ -50,6 +71,8 @@ var relay = createServer({
50
71
  pinHash: config.pinHash || null,
51
72
  port: config.port,
52
73
  debug: config.debug || false,
74
+ dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
75
+ lanHost: lanIp ? lanIp + ":" + config.port : null,
53
76
  });
54
77
 
55
78
  // --- Register projects ---
@@ -179,6 +202,11 @@ var ipc = createIPCServer(socketPath(), function (msg) {
179
202
  // --- Start listening ---
180
203
  relay.server.on("error", function (err) {
181
204
  console.error("[daemon] Server error:", err.message);
205
+ writeCrashInfo({
206
+ reason: "Server error: " + err.message,
207
+ pid: process.pid,
208
+ time: Date.now(),
209
+ });
182
210
  process.exit(1);
183
211
  });
184
212
 
@@ -191,6 +219,23 @@ relay.server.listen(config.port, function () {
191
219
  // Update PID in config
192
220
  config.pid = process.pid;
193
221
  saveConfig(config);
222
+
223
+ // Check for crash info from a previous crash and notify clients
224
+ var crashInfo = readCrashInfo();
225
+ if (crashInfo) {
226
+ console.log("[daemon] Recovered from crash at", new Date(crashInfo.time).toISOString());
227
+ console.log("[daemon] Crash reason:", crashInfo.reason);
228
+ // Delay notification so clients have time to reconnect
229
+ setTimeout(function () {
230
+ relay.broadcastAll({
231
+ type: "toast",
232
+ level: "warn",
233
+ message: "Server recovered from a crash and was automatically restarted.",
234
+ detail: crashInfo.reason || null,
235
+ });
236
+ }, 3000);
237
+ clearCrashInfo();
238
+ }
194
239
  });
195
240
 
196
241
  // --- HTTP onboarding server (only when TLS is active) ---
@@ -233,6 +278,8 @@ function gracefulShutdown() {
233
278
  }
234
279
  } catch (e) {}
235
280
 
281
+ relay.destroyAll();
282
+
236
283
  if (relay.onboardingServer) {
237
284
  relay.onboardingServer.close();
238
285
  }
@@ -258,5 +305,10 @@ if (process.platform === "win32") {
258
305
 
259
306
  process.on("uncaughtException", function (err) {
260
307
  console.error("[daemon] Uncaught exception:", err);
308
+ writeCrashInfo({
309
+ reason: err ? (err.stack || err.message || String(err)) : "unknown",
310
+ pid: process.pid,
311
+ time: Date.now(),
312
+ });
261
313
  gracefulShutdown();
262
314
  });
package/lib/pages.js CHANGED
@@ -549,6 +549,12 @@ function pushDone() {
549
549
  pushStatus.className = "check-status ok";
550
550
  pushStatus.textContent = "Push notifications enabled!";
551
551
  fireConfetti();
552
+ navigator.serviceWorker.ready.then(function(reg) {
553
+ reg.showNotification("\ud83c\udf89 Welcome to Claude Relay!", {
554
+ body: "\ud83d\udd14 You\u2019ll be notified when Claude responds.",
555
+ tag: "claude-welcome",
556
+ });
557
+ }).catch(function() {});
552
558
  setTimeout(function() { nextStep(); }, 1200);
553
559
  }
554
560
 
@@ -582,10 +588,16 @@ function enablePush() {
582
588
  });
583
589
  })
584
590
  .then(function(sub) {
591
+ var prevEndpoint = localStorage.getItem("push-endpoint");
592
+ localStorage.setItem("push-endpoint", sub.endpoint);
593
+ var payload = { subscription: sub.toJSON() };
594
+ if (prevEndpoint && prevEndpoint !== sub.endpoint) {
595
+ payload.replaceEndpoint = prevEndpoint;
596
+ }
585
597
  return fetch("/api/push-subscribe", {
586
598
  method: "POST",
587
599
  headers: { "Content-Type": "application/json" },
588
- body: JSON.stringify(sub.toJSON()),
600
+ body: JSON.stringify(payload),
589
601
  });
590
602
  })
591
603
  .then(pushDone)
@@ -683,6 +695,15 @@ function dashboardPageHtml(projects, version) {
683
695
  '<div class="subtitle">Select a project</div>' +
684
696
  '<div class="cards">' + cards + '</div>' +
685
697
  '<div class="footer">v' + escapeHtml(version || "") + '</div>' +
698
+ '<style>' +
699
+ '.toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:#3A3936;border:1px solid #DA7756;color:#E8E5DE;padding:12px 24px;border-radius:8px;font-size:14px;z-index:999;opacity:0;transition:opacity .3s}' +
700
+ '.toast.show{opacity:1}' +
701
+ '</style>' +
702
+ '<script>var s=window.matchMedia("(display-mode:standalone)").matches||navigator.standalone;' +
703
+ 'if(s&&!localStorage.getItem("setup-done")){var t=/^100\\./.test(location.hostname);location.replace("/setup"+(t?"":"?mode=lan"));}' +
704
+ 'var p=new URLSearchParams(location.search);var g=p.get("gone");' +
705
+ 'if(g){history.replaceState(null,"","/");var d=document.createElement("div");d.className="toast";d.textContent="Project \\""+g+"\\" is no longer available";document.body.appendChild(d);' +
706
+ 'requestAnimationFrame(function(){d.className="toast show"});setTimeout(function(){d.className="toast";setTimeout(function(){d.remove()},300)},5000);}</script>' +
686
707
  '</body></html>';
687
708
  }
688
709