@pablopunk/webr 0.1.7 → 0.2.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/server/cli.ts CHANGED
@@ -1,7 +1,5 @@
1
- import { join } from 'node:path';
2
1
  import { parseArgs } from 'node:util';
3
2
  import { renderUnicode } from 'uqr';
4
- import { webrHome } from './home';
5
3
  import { runServer } from './run';
6
4
  import { explicitPort, knownPort } from './port';
7
5
  import { defaultDeps, installPlugin, pluginStatus, uninstallWebrPlugin, updatePlugin } from './plugin';
@@ -16,7 +14,8 @@ import { refreshLatestVersionCache, updateHint } from './update/hint';
16
14
  const HELP = `Webr — control your Herdr agents from anywhere.
17
15
 
18
16
  Usage
19
- webr [start] [options] Run the server in this terminal
17
+ webr Show status and this help
18
+ webr start [options] Run the server in this terminal
20
19
  webr plugin install [options] Start Webr whenever the Herdr server starts, and open it
21
20
  webr plugin uninstall Remove the Herdr plugin
22
21
  webr plugin status Show whether the plugin is installed and Webr is running
@@ -48,7 +47,6 @@ function applyServerFlags(values: ReturnType<typeof parseCommand>['values']) {
48
47
  if (values.port) process.env.PORT = String(explicitPort(['--port', values.port], {}));
49
48
  if (values.host) process.env.HOST = values.host;
50
49
  if (values.origin) process.env.WEBR_ORIGIN = values.origin;
51
- process.env.WEBR_DATABASE ??= join(webrHome(), 'gateway.sqlite');
52
50
  }
53
51
 
54
52
  function printUpdateHint() {
@@ -92,15 +90,25 @@ async function openWhenUp(port: number) {
92
90
  }
93
91
  }
94
92
 
93
+ async function printStatus(values: ReturnType<typeof parseCommand>['values']) {
94
+ (await statusLines(knownPort(values.port ? ['--port', values.port] : []), modeLines(pluginMode(defaultDeps().run, defaultDeps().stateDir)))).forEach((line) => console.log(line));
95
+ }
96
+
97
+ async function printStatusAndHelp(values: ReturnType<typeof parseCommand>['values']) {
98
+ await printStatus(values);
99
+ console.log(`\n${HELP}`);
100
+ }
101
+
95
102
  export async function main(argv: string[]) {
96
103
  const { values, positionals } = parseCommand(argv);
97
- const [command = 'start', subcommand] = positionals;
104
+ const [command, subcommand] = positionals;
98
105
  if (values.help || command === 'help') return void console.log(HELP);
99
106
  if (values.version) { console.log(packageInfo().version); return printUpdateHint(); }
107
+ if (!command) return printStatusAndHelp(values);
100
108
  if (command === 'start') { applyServerFlags(values); await requireHerdr(); return runServer(); }
101
109
  if (command === 'update') return void (await runUpdate(realUpdateEnvironment(() => updatePlugin(defaultDeps())))).forEach((line) => console.log(line));
102
110
  if (command === 'refresh-update-cache') return refreshLatestVersionCache().catch(() => undefined);
103
- if (command === 'status') return void (await statusLines(knownPort(values.port ? ['--port', values.port] : []), modeLines(pluginMode(defaultDeps().run, defaultDeps().stateDir)))).forEach((line) => console.log(line));
111
+ if (command === 'status') return printStatus(values);
104
112
  if (command === 'open') return void (await openWebr(knownPort(values.port ? ['--port', values.port] : []))).forEach((line) => console.log(line));
105
113
  if (command === 'invite') return invite(knownPort(values.port ? ['--port', values.port] : []));
106
114
  if (command === 'plugin' && ['install', 'uninstall', 'status'].includes(subcommand ?? '')) return pluginCommand(subcommand!, values);
@@ -7,6 +7,7 @@ type Address = Parameters<typeof isPortOpen>[0];
7
7
 
8
8
  const STOP_ATTEMPTS = 50;
9
9
  const STOP_POLL_MS = 100;
10
+ const START_ATTEMPTS = 100;
10
11
 
11
12
  const pidPath = (stateDir: string) => join(stateDir, PID_FILE);
12
13
  const sleep = (ms: number) => new Promise((done) => setTimeout(done, ms));
@@ -30,10 +31,18 @@ async function waitUntilClosed(address: Address) {
30
31
  return false;
31
32
  }
32
33
 
34
+ async function waitUntilListeningWhileAlive(pid: number, address: Address) {
35
+ for (let attempt = 0; attempt < START_ATTEMPTS && isAlive(pid); attempt++) {
36
+ if (await isPortOpen(address)) return true;
37
+ await sleep(STOP_POLL_MS);
38
+ }
39
+ return false;
40
+ }
41
+
33
42
  export async function stopPluginWebr(stateDir: string, address: Address) {
34
43
  const pid = recordedPid(stateDir);
35
44
  rmSync(pidPath(stateDir), { force: true });
36
- if (!pid || !isAlive(pid) || !(await isPortOpen(address))) return false;
45
+ if (!pid || !isAlive(pid) || !(await waitUntilListeningWhileAlive(pid, address))) return false;
37
46
  process.kill(pid, 'SIGTERM');
38
47
  return waitUntilClosed(address);
39
48
  }
@@ -0,0 +1,26 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { statSync, unwatchFile, watchFile, writeFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { distRoot } from '../src/server/dist';
5
+
6
+ const POLL_MS = 2000;
7
+
8
+ const builtEntry = () => resolve(distRoot(), 'server/entry.mjs');
9
+ const builtAt = () => { try { return statSync(builtEntry()).mtimeMs; } catch { return undefined; } };
10
+
11
+ export const relaunchSelf = () => spawn(process.execPath, [...process.execArgv, ...process.argv.slice(1)], { detached: true, stdio: 'ignore', env: process.env }).unref();
12
+
13
+ export const recordPid = (stateDir: string | undefined, pidFile: string) => { if (stateDir) writeFileSync(resolve(stateDir, pidFile), String(process.pid)); };
14
+
15
+ export function restartWhenRebuilt(stop: () => Promise<void>, relaunch = relaunchSelf) {
16
+ const startedAt = builtAt();
17
+ if (startedAt === undefined) return () => {};
18
+ const entry = builtEntry();
19
+ watchFile(entry, { interval: POLL_MS }, (current) => {
20
+ if (current.mtimeMs === startedAt) return;
21
+ unwatchFile(entry);
22
+ console.log('Webr was updated on disk. Restarting to serve the new build.');
23
+ void stop().finally(() => { relaunch(); process.exit(0); });
24
+ });
25
+ return () => unwatchFile(entry);
26
+ }
package/server/run.ts CHANGED
@@ -6,9 +6,16 @@ import { HerdrTarget } from '../src/server/transport/herdr-target';
6
6
  import { createHost } from '../src/server/host';
7
7
  import { publicUrls } from '../src/server/public-urls';
8
8
  import { detectTailscaleHost } from '../src/server/tailscale-host';
9
+ import { ensureTailscaleHttps } from '../src/server/tailscale-serve';
9
10
  import { isLoopbackHost } from '../src/server/auth/access';
10
11
  import { resolvePort } from './port';
12
+ import { webrHome } from './home';
13
+ import { Transcriber } from '../src/server/voice/transcriber';
14
+ import { modelDirectory } from '../src/server/voice/model';
15
+ import { join } from 'node:path';
11
16
  import { builtSsr, devSsr } from './ssr';
17
+ import { recordPid, restartWhenRebuilt } from './rebuilt-restart';
18
+ import { PID_FILE } from './plugin/files';
12
19
 
13
20
  const WILDCARD_HOSTS = ['0.0.0.0', '::'];
14
21
 
@@ -20,15 +27,19 @@ export async function runServer() {
20
27
  if (new URL(origin).origin !== origin || new URL(origin).username || new URL(origin).password) throw new Error('WEBR_ORIGIN must be a normalized HTTP or HTTPS origin without credentials or a path');
21
28
  if (!['http:', 'https:'].includes(new URL(origin).protocol)) throw new Error('WEBR_ORIGIN must use HTTP or HTTPS');
22
29
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid port');
23
- const database = new MetadataDatabase(process.env.WEBR_DATABASE ?? '.data/gateway.sqlite');
30
+ const database = new MetadataDatabase(process.env.WEBR_DATABASE ?? join(webrHome(), 'gateway.sqlite'));
24
31
  const profiles = await loadRegistry(process.env.WEBR_TARGETS);
25
32
  const manager = new RuntimeManager(database, profiles.map((profile) => new HerdrTarget(profile)));
26
33
  const ssr = process.env.WEBR_DEV === '1' ? await devSsr(port, host) : await builtSsr();
27
34
  const tls = tlsEnabled ? { cert: await readFile(process.env.WEBR_TLS_CERT!), key: await readFile(process.env.WEBR_TLS_KEY!) } : undefined;
28
- const urls = publicUrls(origin, host, port, undefined, await detectTailscaleHost());
29
- const app = await createHost(manager, origin, ssr.handler, tls, undefined, { publicUrls: urls });
35
+ const managesTailscale = !process.env.WEBR_ORIGIN && [...WILDCARD_HOSTS, '127.0.0.1'].includes(host);
36
+ const tailscale = managesTailscale ? await ensureTailscaleHttps(port) : undefined;
37
+ const urls = [...(tailscale?.status === 'ready' ? [tailscale.url] : []), ...publicUrls(origin, host, port, undefined, await detectTailscaleHost())];
38
+ const app = await createHost(manager, origin, ssr.handler, tls, undefined, { publicUrls: urls, tailscale: tailscale?.status }, { devHmrPort: ssr.hmrPort, transcriber: new Transcriber(modelDirectory(webrHome())) });
30
39
  await app.listen({ host, port });
31
40
  manager.start();
41
+ recordPid(process.env.HERDR_PLUGIN_STATE_DIR, PID_FILE);
42
+ if (process.env.WEBR_DEV !== '1') restartWhenRebuilt(async () => { await Promise.all([app.close(), ssr.stop()]); database.close(); });
32
43
  console.log(`Webr listening at ${origin}`);
33
44
  if (!isLoopbackHost(host) || urls.length) console.log(`Other devices connect at:\n${(urls.length ? urls : [origin]).map((url) => ` ${url}`).join('\n')}\nThey ask for access and you approve them in Webr on this computer.`);
34
45
  for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { void Promise.all([app.close(), ssr.stop()]).finally(() => { database.close(); process.exit(0); }); });
package/server/ssr.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { pathToFileURL } from 'node:url';
3
3
  import { distRoot } from '../src/server/dist';
4
+ import { HMR_PATH } from '../src/server/dev-hmr-proxy';
4
5
 
5
6
  type Locals = Record<string, unknown>;
6
- export type Ssr = { handler: (request: object, response: unknown, next: unknown, locals: Locals) => void; stop: () => Promise<void> };
7
+ export type Ssr = { handler: (request: object, response: unknown, next: unknown, locals: Locals) => void; stop: () => Promise<void>; hmrPort?: number };
7
8
 
8
9
  const astroLocalsSymbol = Symbol.for('astro.locals');
9
10
 
@@ -14,10 +15,10 @@ export async function builtSsr(): Promise<Ssr> {
14
15
 
15
16
  export async function devSsr(port: number, host = '127.0.0.1'): Promise<Ssr> {
16
17
  const { dev } = await import('astro');
17
- const astro = await dev({ server: { port: port + 2, host }, vite: { server: { allowedHosts: true, hmr: { port: port + 1 } } } });
18
+ const astro = await dev({ server: { port: port + 2, host }, vite: { server: { allowedHosts: true, hmr: { path: HMR_PATH } } } });
18
19
  const handler: Ssr['handler'] = (request, response, _next, locals) => {
19
20
  Reflect.set(request, astroLocalsSymbol, locals);
20
21
  astro.handle(request as never, response as never);
21
22
  };
22
- return { handler, stop: () => astro.stop() };
23
+ return { handler, stop: () => astro.stop(), hmrPort: port + 2 };
23
24
  }
@@ -27,16 +27,18 @@ export const realUpdateEnvironment = (restartPlugin: UpdateEnvironment['restartP
27
27
  restartPlugin,
28
28
  });
29
29
 
30
+ const restartedPlugin = async (environment: UpdateEnvironment) => (await environment.restartPlugin()) ?? ['Restart Herdr to use the new version.'];
31
+
30
32
  export async function runUpdate(environment: UpdateEnvironment) {
31
33
  const { name, version } = packageInfo();
32
34
  const manager = detectPackageManager(environment.installPath());
33
35
  if (!manager) throw new Error('This copy of Webr was not installed globally, so it cannot update itself. If it is a git checkout, pull and rebuild it.');
34
36
  const latest = await environment.latestVersion(name);
35
37
  writeCachedLatest(latest);
36
- if (!isNewerVersion(latest, version)) return [`Webr ${version} is the latest version.`];
38
+ if (!isNewerVersion(latest, version)) return [`Webr ${version} is the latest version.`, ...(await restartedPlugin(environment))];
37
39
  const command = globalInstallCommand(manager, name, latest);
38
40
  if (!environment.install(command)) throw new Error(`${manager} could not install ${name}@${latest}. Run it yourself to see why: ${command.command} ${command.args.join(' ')}`);
39
41
  const installed = environment.installedVersion();
40
42
  if (installed !== latest) throw new Error(`${manager} finished, but Webr is still ${installed ?? 'unreadable'} instead of ${latest}. Its package cache may be stale. Run it yourself: ${command.command} ${command.args.join(' ')}`);
41
- return [`Updated Webr ${version} → ${latest}.`, ...((await environment.restartPlugin()) ?? ['Restart Herdr to use the new version.'])];
43
+ return [`Updated Webr ${version} → ${latest}.`, ...(await restartedPlugin(environment))];
42
44
  }
@@ -4,7 +4,7 @@ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
4
4
  const FORWARDING_HEADERS = ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'forwarded', 'via'];
5
5
 
6
6
  const hostnameOf = (host: string | undefined) => { try { return new URL(`http://${host}`).hostname; } catch { return undefined; } };
7
- const isLoopbackAddress = (address: string | undefined) => !!address && (address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.'));
7
+ export const isLoopbackAddress = (address: string | undefined) => !!address && (address === '::1' || address.startsWith('127.') || address.startsWith('::ffff:127.'));
8
8
  const isForwarded = (headers: IncomingHttpHeaders) => FORWARDING_HEADERS.some((name) => name in headers) || Object.keys(headers).some((name) => name.startsWith('tailscale-'));
9
9
  const isIpLiteral = (hostname: string) => hostname.startsWith('[') || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
10
10
  const isNameControlledByNetwork = (hostname: string) => hostname.endsWith('.local') || hostname.endsWith('.ts.net');
@@ -2,14 +2,14 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
2
2
  import type { WebSocket } from 'ws';
3
3
  import { z } from 'zod';
4
4
  import type { MetadataDatabase } from '../storage/database';
5
- import { isLocalRequest, sourceAddress } from './access';
5
+ import { isLocalRequest, isLoopbackAddress, sourceAddress } from './access';
6
6
  import { AuditLog } from './audit';
7
7
  import { ApproverPresence } from './presence';
8
8
  import { pairPage } from './pair-page';
9
9
  import { PairingService, RateLimiter, formatInviteToken } from './pairing';
10
10
  import { SessionStore, clearedSessionCookie, deviceNameFrom, sessionCookie, type DeviceSession } from './sessions';
11
11
 
12
- export type AuthOptions = { publicUrls?: string[] };
12
+ export type AuthOptions = { publicUrls?: string[]; tailscale?: 'ready' | 'https-disabled' | 'unavailable' };
13
13
  export type Access = { local: boolean; session?: DeviceSession };
14
14
 
15
15
  const publicRoutes: [method: string, path: RegExp][] = [
@@ -47,8 +47,15 @@ export class Auth {
47
47
  return reply.code(401).send({ error: 'unauthorized' });
48
48
  }
49
49
 
50
+ private isHttpsThroughLocalProxy(request: FastifyRequest) { return request.headers['x-forwarded-proto'] === 'https' && isLoopbackAddress(request.socket.remoteAddress); }
51
+
52
+ private protocolOf(request: FastifyRequest) {
53
+ if (request.headers.host === this.origin.host) return this.origin.protocol;
54
+ return this.isHttpsThroughLocalProxy(request) ? 'https:' : `${request.protocol}:`;
55
+ }
56
+
50
57
  expectedOrigin(request: FastifyRequest) {
51
- return `${request.headers.host === this.origin.host ? this.origin.protocol : `${request.protocol}:`}//${request.headers.host}`;
58
+ return `${this.protocolOf(request)}//${request.headers.host}`;
52
59
  }
53
60
 
54
61
  routes(app: FastifyInstance) {
@@ -97,6 +104,7 @@ export class Auth {
97
104
  this.audit.record('invite_created', { source: sourceOf(request) });
98
105
  return { token: formatInviteToken(invite.token), expiresAt: invite.expiresAt, urls: (this.options.publicUrls ?? []).map((base) => `${base}/#token=${invite.token}`) };
99
106
  });
107
+ app.get('/api/remote/tailscale', async () => ({ status: this.options.tailscale ?? 'ready' }));
100
108
  app.get('/api/audit', async () => this.audit.recent());
101
109
  app.get('/api/devices', async (request) => this.sessions.list().map((device) => ({ ...device, current: device.id === this.access(request).session?.id })));
102
110
  app.post('/api/devices/:id/revoke', async (request, reply) => {
@@ -120,7 +128,7 @@ export class Auth {
120
128
  return publicRoutes.some(([method, pattern]) => method === request.method && pattern.test(path));
121
129
  }
122
130
 
123
- private isSecure(request: FastifyRequest) { return this.origin.protocol === 'https:' && request.headers.host === this.origin.host; }
131
+ private isSecure(request: FastifyRequest) { return this.protocolOf(request) === 'https:'; }
124
132
 
125
133
  private grant(request: FastifyRequest, reply: FastifyReply, deviceName: string) {
126
134
  reply.header('Set-Cookie', sessionCookie(this.sessions.create(deviceName).token, this.isSecure(request)));
@@ -0,0 +1,16 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { WebSocket } from 'ws';
3
+
4
+ export const HMR_PATH = '/__vite_hmr';
5
+ const HMR_PROTOCOL = 'vite-hmr';
6
+
7
+ export function registerDevHmrProxy(app: FastifyInstance, vitePort: number) {
8
+ app.get(HMR_PATH, { websocket: true }, (browser) => {
9
+ const vite = new WebSocket(`ws://127.0.0.1:${vitePort}${HMR_PATH}`, HMR_PROTOCOL);
10
+ const forwardedWhileConnecting: [Buffer, boolean][] = [];
11
+ browser.on('message', (data, isBinary) => { if (vite.readyState === WebSocket.OPEN) vite.send(data, { binary: isBinary }); else forwardedWhileConnecting.push([data as Buffer, isBinary]); });
12
+ vite.on('open', () => forwardedWhileConnecting.forEach(([data, isBinary]) => vite.send(data, { binary: isBinary })));
13
+ vite.on('message', (data, isBinary) => browser.send(data, { binary: isBinary }));
14
+ for (const [from, to] of [[browser, vite], [vite, browser]] as const) { from.on('close', () => to.close()); from.on('error', () => to.close()); }
15
+ });
16
+ }
@@ -11,12 +11,15 @@ import { createWorkspace } from './runtime/workspace';
11
11
  import { perfLog, perfLogEnabled } from './perf-log';
12
12
  import { Auth, type AuthOptions } from './auth/auth';
13
13
  import { isTrustedHost } from './auth/access';
14
+ import { registerDevHmrProxy } from './dev-hmr-proxy';
15
+ import { registerVoiceRoutes } from './voice/routes';
16
+ import type { Transcriber } from './voice/transcriber';
14
17
  import { MAX_IMAGE_BYTES, UploadStore, uploadContentTypes } from './uploads';
15
18
 
16
19
  const localOwner = 'local';
17
20
  type SsrHandler = (request: FastifyRequest['raw'], response: import('node:http').ServerResponse, next: (error?: unknown) => void, locals: Record<string, unknown>) => void;
18
21
 
19
- export async function createHost(manager: RuntimeManager, origin: string, ssr?: SsrHandler, tls?: { key: Buffer; cert: Buffer }, uploads = new UploadStore(), authOptions: AuthOptions = {}) {
22
+ export async function createHost(manager: RuntimeManager, origin: string, ssr?: SsrHandler, tls?: { key: Buffer; cert: Buffer }, uploads = new UploadStore(), authOptions: AuthOptions = {}, { devHmrPort, transcriber }: { devHmrPort?: number; transcriber?: Transcriber } = {}) {
20
23
  const app = Fastify({ logger: false, bodyLimit: 16 * 1024, ...(tls ? { https: tls } : {}), requestTimeout: 10_000 });
21
24
  const configuredOrigin = new URL(origin);
22
25
  const auth = new Auth(manager.database, configuredOrigin, authOptions);
@@ -43,6 +46,8 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
43
46
  await app.register(websocket, { options: { maxPayload: 32 * 1024, perMessageDeflate: false } });
44
47
  const hub = registerWebsockets(app, manager);
45
48
  auth.routes(app);
49
+ if (devHmrPort) registerDevHmrProxy(app, devHmrPort);
50
+ if (transcriber) registerVoiceRoutes(app, transcriber);
46
51
  app.get('/api/catalog/machines', async () => manager.bootstrap().machines);
47
52
  app.get('/api/catalog/:machineId', async (request) => {
48
53
  const { machineId } = z.object({ machineId: opaqueId }).parse(request.params);
@@ -164,6 +169,6 @@ export async function createHost(manager: RuntimeManager, origin: string, ssr?:
164
169
  ssr(request.raw, reply.raw, () => { if (!reply.raw.headersSent) { reply.raw.statusCode = 404; reply.raw.end('Not found'); } }, { runtime: manager, accountId: localOwner });
165
170
  });
166
171
  }
167
- app.addHook('onClose', async () => manager.close());
172
+ app.addHook('onClose', async () => { transcriber?.close(); return manager.close(); });
168
173
  return app;
169
174
  }
@@ -0,0 +1,42 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const run = promisify(execFile);
5
+ const TAILSCALE_BINARIES = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale'];
6
+ const HTTPS_PORTS = [443, 8443, 10000];
7
+
8
+ type ServeStatus = { TCP?: Record<string, unknown>; Web?: Record<string, { Handlers?: Record<string, { Proxy?: string }> }> };
9
+ type TailscaleStatus = { Self?: { DNSName?: string }; CertDomains?: string[] };
10
+
11
+ const withoutTrailingDot = (name: string) => name.replace(/\.$/, '');
12
+ const portOfProxy = (proxy: string | undefined) => { try { return Number(new URL(proxy ?? '').port); } catch { return undefined; } };
13
+ const proxiesTo = (status: ServeStatus, hostPort: string, port: number) => Object.values(status.Web?.[hostPort]?.Handlers ?? {}).some((handler) => portOfProxy(handler.Proxy) === port);
14
+ const isFree = (status: ServeStatus, name: string, httpsPort: number) => !(String(httpsPort) in (status.TCP ?? {})) && !(`${name}:${httpsPort}` in (status.Web ?? {}));
15
+ const urlFor = (name: string, httpsPort: number) => `https://${name}${httpsPort === 443 ? '' : `:${httpsPort}`}`;
16
+
17
+ export function choosePort(status: ServeStatus, name: string, webrPort: number) {
18
+ const reusable = HTTPS_PORTS.find((httpsPort) => proxiesTo(status, `${name}:${httpsPort}`, webrPort));
19
+ return reusable !== undefined ? { port: reusable, exists: true } : { port: HTTPS_PORTS.find((httpsPort) => isFree(status, name, httpsPort)), exists: false };
20
+ }
21
+
22
+ async function tailscale(args: string[]) {
23
+ for (const binary of TAILSCALE_BINARIES) {
24
+ try { return (await run(binary, args, { timeout: 10_000 })).stdout; } catch { /* try the next binary */ }
25
+ }
26
+ throw new Error('tailscale unavailable');
27
+ }
28
+
29
+ export type TailscaleHttps = { status: 'ready'; url: string } | { status: 'https-disabled' | 'unavailable' };
30
+
31
+ export async function ensureTailscaleHttps(webrPort: number): Promise<TailscaleHttps> {
32
+ try {
33
+ const { Self, CertDomains } = JSON.parse(await tailscale(['status', '--json'])) as TailscaleStatus;
34
+ const name = Self?.DNSName && withoutTrailingDot(Self.DNSName);
35
+ if (!name) return { status: 'unavailable' };
36
+ if (!CertDomains?.includes(name)) return { status: 'https-disabled' };
37
+ const { port, exists } = choosePort(JSON.parse(await tailscale(['serve', 'status', '--json'])) as ServeStatus, name, webrPort);
38
+ if (port === undefined) return { status: 'unavailable' };
39
+ if (!exists) await tailscale(['serve', '--bg', `--https=${port}`, `http://127.0.0.1:${webrPort}`]);
40
+ return { status: 'ready', url: urlFor(name, port) };
41
+ } catch { return { status: 'unavailable' }; }
42
+ }
@@ -0,0 +1,35 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { mkdir, rename, rm, stat } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+
7
+ const REPOSITORY = 'https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/resolve/main';
8
+ const MODEL_FILES = { encoder: 'encoder.int8.onnx', decoder: 'decoder.int8.onnx', joiner: 'joiner.int8.onnx', tokens: 'tokens.txt' } as const;
9
+
10
+ export type ModelFiles = Record<keyof typeof MODEL_FILES, string>;
11
+
12
+ export const modelDirectory = (home: string) => join(home, 'models', 'parakeet-tdt-0.6b-v3-int8');
13
+ export const modelFiles = (directory: string) => Object.fromEntries(Object.entries(MODEL_FILES).map(([key, name]) => [key, join(directory, name)])) as ModelFiles;
14
+
15
+ const exists = (path: string) => stat(path).then((entry) => entry.size > 0, () => false);
16
+
17
+ export async function isModelDownloaded(directory: string) {
18
+ return (await Promise.all(Object.values(modelFiles(directory)).map(exists))).every(Boolean);
19
+ }
20
+
21
+ async function downloadFile(url: string, destination: string) {
22
+ if (await exists(destination)) return;
23
+ const response = await fetch(url);
24
+ if (!response.ok || !response.body) throw new Error(`Could not download ${url}: HTTP ${response.status}`);
25
+ const partial = `${destination}.part`;
26
+ try {
27
+ await pipeline(Readable.fromWeb(response.body as never), createWriteStream(partial));
28
+ await rename(partial, destination);
29
+ } catch (error) { await rm(partial, { force: true }); throw error; }
30
+ }
31
+
32
+ export async function downloadModel(directory: string, repository = REPOSITORY) {
33
+ await mkdir(directory, { recursive: true });
34
+ for (const name of Object.values(MODEL_FILES)) await downloadFile(`${repository}/${name}`, join(directory, name));
35
+ }
@@ -0,0 +1,29 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { z } from 'zod';
3
+ import type { Transcriber } from './transcriber';
4
+
5
+ const PCM_CONTENT_TYPE = 'audio/l16';
6
+ const MAX_SECONDS = 600;
7
+ const MAX_SAMPLE_RATE = 48_000;
8
+ export const MAX_VOICE_BYTES = MAX_SECONDS * MAX_SAMPLE_RATE * 2;
9
+
10
+ const int16ToFloat32 = (pcm: Buffer) => {
11
+ const samples = new Float32Array(Math.floor(pcm.length / 2));
12
+ for (let index = 0; index < samples.length; index++) samples[index] = pcm.readInt16LE(index * 2) / 32768;
13
+ return samples;
14
+ };
15
+
16
+ export function registerVoiceRoutes(app: FastifyInstance, transcriber: Transcriber) {
17
+ app.addContentTypeParser(PCM_CONTENT_TYPE, { parseAs: 'buffer', bodyLimit: MAX_VOICE_BYTES }, (_request, body, done) => done(null, body));
18
+ app.get('/api/voice', async () => ({ state: await transcriber.state() }));
19
+ app.post('/api/voice/warm', async () => {
20
+ void transcriber.warm().catch(() => undefined);
21
+ return { state: await transcriber.state() };
22
+ });
23
+ app.post('/api/voice/transcribe', { bodyLimit: MAX_VOICE_BYTES }, async (request, reply) => {
24
+ const { sampleRate } = z.object({ sampleRate: z.coerce.number().int().min(8000).max(MAX_SAMPLE_RATE) }).strict().parse(request.query);
25
+ if (!Buffer.isBuffer(request.body) || !request.body.length) return reply.code(400).send({ error: 'invalid_request' });
26
+ if (await transcriber.state() === 'unavailable') return reply.code(409).send({ error: 'voice_unavailable' });
27
+ return { text: await transcriber.transcribe(int16ToFloat32(request.body), sampleRate) };
28
+ });
29
+ }
@@ -0,0 +1,80 @@
1
+ import { fork, type ChildProcess } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { downloadModel, isModelDownloaded, modelFiles } from './model';
5
+
6
+ export type VoiceState = 'unavailable' | 'needs-download' | 'downloading' | 'ready';
7
+ type Reply = { id: number; text?: string; error?: string };
8
+ type Pending = { resolve: (text: string) => void; reject: (error: Error) => void };
9
+
10
+ const IDLE_UNLOAD_MS = 5 * 60 * 1000;
11
+ const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
12
+
13
+ const recognizerInstalled = () => { try { createRequire(import.meta.url).resolve('sherpa-onnx-node'); return true; } catch { return false; } };
14
+
15
+ export class Transcriber {
16
+ private download?: Promise<void>;
17
+ private worker?: Promise<ChildProcess>;
18
+ private pending = new Map<number, Pending>();
19
+ private nextId = 0;
20
+ private idleTimer?: ReturnType<typeof setTimeout>;
21
+ private readonly installed: boolean;
22
+
23
+ constructor(private readonly directory: string, private readonly idleUnloadMs = IDLE_UNLOAD_MS, installed = recognizerInstalled()) {
24
+ this.installed = installed;
25
+ }
26
+
27
+ async state(): Promise<VoiceState> {
28
+ if (!this.installed) return 'unavailable';
29
+ if (this.download) return 'downloading';
30
+ return await isModelDownloaded(this.directory) ? 'ready' : 'needs-download';
31
+ }
32
+
33
+ warm() {
34
+ if (!this.installed) return Promise.reject(new Error('voice_unavailable'));
35
+ this.keepAliveWhileIdle();
36
+ return this.worker ??= this.ensureDownloaded().then(() => this.startWorker()).catch((error) => { this.worker = undefined; throw error; });
37
+ }
38
+
39
+ async transcribe(samples: Float32Array, sampleRate: number) {
40
+ const worker = await this.warm();
41
+ const id = this.nextId++;
42
+ const text = new Promise<string>((resolve, reject) => this.pending.set(id, { resolve, reject }));
43
+ worker.send({ id, samples, sampleRate });
44
+ try { return await text; } finally { this.keepAliveWhileIdle(); }
45
+ }
46
+
47
+ close() { clearTimeout(this.idleTimer); void this.worker?.then((worker) => worker.kill(), () => undefined); this.worker = undefined; }
48
+
49
+ private ensureDownloaded() {
50
+ return this.download ??= isModelDownloaded(this.directory)
51
+ .then((ready) => ready ? undefined : downloadModel(this.directory))
52
+ .finally(() => { this.download = undefined; });
53
+ }
54
+
55
+ private startWorker() {
56
+ return new Promise<ChildProcess>((resolve, reject) => {
57
+ const worker = fork(WORKER_PATH, [], { serialization: 'advanced', stdio: ['ignore', 'inherit', 'inherit', 'ipc'], env: { ...process.env, WEBR_VOICE_MODEL: JSON.stringify(modelFiles(this.directory)) } });
58
+ worker.on('message', (message: Reply & { ready?: boolean }) => { if (message.ready) resolve(worker); else this.settle(message); });
59
+ worker.once('exit', (code) => { this.forgetWorker(worker, new Error(`voice_worker_exited_${code}`)); reject(new Error('voice_worker_failed')); });
60
+ });
61
+ }
62
+
63
+ private settle({ id, text, error }: Reply) {
64
+ const request = this.pending.get(id);
65
+ this.pending.delete(id);
66
+ if (error !== undefined) request?.reject(new Error(error)); else request?.resolve(text ?? '');
67
+ }
68
+
69
+ private forgetWorker(worker: ChildProcess, reason: Error) {
70
+ void this.worker?.then((current) => { if (current === worker) this.worker = undefined; }, () => undefined);
71
+ for (const request of this.pending.values()) request.reject(reason);
72
+ this.pending.clear();
73
+ }
74
+
75
+ private keepAliveWhileIdle() {
76
+ clearTimeout(this.idleTimer);
77
+ this.idleTimer = setTimeout(() => { if (!this.pending.size) this.close(); }, this.idleUnloadMs);
78
+ this.idleTimer.unref();
79
+ }
80
+ }
@@ -0,0 +1,23 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ const { OfflineRecognizer } = createRequire(import.meta.url)('sherpa-onnx-node');
4
+ const files = JSON.parse(process.env.WEBR_VOICE_MODEL);
5
+
6
+ const recognizer = new OfflineRecognizer({
7
+ featConfig: { sampleRate: 16000, featureDim: 80 },
8
+ modelConfig: { transducer: { encoder: files.encoder, decoder: files.decoder, joiner: files.joiner }, tokens: files.tokens, modelType: 'nemo_transducer', numThreads: 4, provider: 'cpu', debug: 0 },
9
+ });
10
+
11
+ function transcribe({ samples, sampleRate }) {
12
+ const stream = recognizer.createStream();
13
+ stream.acceptWaveform({ samples, sampleRate });
14
+ recognizer.decode(stream);
15
+ return recognizer.getResult(stream).text.trim();
16
+ }
17
+
18
+ process.on('message', (request) => {
19
+ try { process.send({ id: request.id, text: transcribe(request) }); }
20
+ catch (error) { process.send({ id: request.id, error: error instanceof Error ? error.message : String(error) }); }
21
+ });
22
+ process.on('disconnect', () => process.exit(0));
23
+ process.send({ ready: true });
@@ -1 +0,0 @@
1
- @font-face{font-family:JetBrains Mono;src:url(/_astro/jetbrains-mono.CFJaiWaY.woff2)format("woff2");font-weight:100 800;font-style:normal;font-display:swap}@font-face{font-family:Webr Symbols;src:url(/_astro/symbols-nerd.CEdkKAK8.woff2)format("woff2");font-display:swap;unicode-range:U+E0A0-E0D7,U+E5FA-E6B7,U+E700-E7C5,U+F400-F4A8}:root{color-scheme:light;--page:#f8f8f7;--surface:#fff;--surface-raised:#fff;--surface-muted:#f4f4f3;--surface-hover:#f0f0ef;--surface-active:#eaeae9;--sidebar:#f3f3f2;--card-hover:#fff;--card-edge:#1a1d2017;--card-shadow:inset 0 1px 2px #fffffff2, 0 1px 2px #00000029, 0 3px 7px #00000012;--card-shadow-hover:inset 0 1px 2px #fffffff2, 0 2px 4px #0000002b, 0 5px 10px #00000014;--ink:#1b1c1d;--ink-soft:#5b5d61;--ink-muted:#8b8d90;--line:#e9e9e7;--line-strong:#dedfdd;--accent:#357dba;--action-blue:#345fba;--accent-soft:#e9f3fa;--focus-ring:#579bcf;--working:#58a47a;--blocked:#dca362;--danger:#c9534d;--done:#7196ca;--idle:#9a9fa3;--terminal-bg:#fefefd;--terminal-ink:#333639;--terminal-cursor:#555b60;--terminal-selection:#bbd8e8;--shadow:0 1px 2px #1c202308, 0 10px 36px #1c202308;--ease:cubic-bezier(.22, 1, .36, 1);--font-ui:"DM Sans", Inter, system-ui, sans-serif;--font-mono:"JetBrains Mono", "Webr Symbols", ui-monospace, Menlo, monospace}html[data-theme=dark]{color-scheme:dark;--page:#121313;--surface:#1b1c1c;--surface-raised:#202121;--surface-muted:#242626;--surface-hover:#2a2c2c;--surface-active:#333535;--sidebar:#111212;--card-hover:#2b2d2d;--card-edge:#ffffff14;--card-shadow:inset 0 1px 2px #ffffff2b, 0 1px 2px #0000005c, 0 3px 7px #0000002e;--card-shadow-hover:inset 0 1px 2px #ffffff40, 0 2px 4px #00000061, 0 5px 10px #0000002e;--ink:#f1f2f0;--ink-soft:#b1b3b0;--ink-muted:#777b7b;--line:#292b2b;--line-strong:#393b3b;--accent:#91cae8;--action-blue:#365eb9;--accent-soft:#203239;--focus-ring:#9dd5e9;--working:#85cba2;--blocked:#ecc188;--danger:#e07a74;--done:#a0bdec;--idle:#858d8b;--terminal-bg:#181a1a;--terminal-ink:#d8dcda;--terminal-cursor:#d8dcda;--terminal-selection:#37505a;--shadow:0 1px 2px #00000024, 0 10px 36px #0000001f}*,:before,:after{box-sizing:border-box}html{background:var(--page)}body{color:var(--ink);background:var(--page);font:13px/1.45 var(--font-ui);-webkit-font-smoothing:antialiased;margin:0}button,input,textarea,select{font:inherit}button{cursor:pointer}a{color:inherit;text-decoration:none}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:0}.app-shell{min-height:100dvh;display:flex}.main-panel{flex-direction:column;flex:1;min-width:0;height:100dvh;min-height:100dvh;display:flex;overflow-y:auto}.icon-button{width:30px;height:30px;color:var(--ink-muted);background:0 0;border:0;border-radius:6px;flex:none;justify-content:center;align-items:center;display:inline-flex}.icon-button:hover{background:var(--surface-hover);color:var(--ink)}.not-found{max-width:680px;margin:14vh auto 0;padding:25px}.not-found h1{font-size:26px;font-weight:600}.not-found p{color:var(--ink-muted)}.mobile-controls{display:none}@media (width<=760px){.mobile-controls{z-index:8;top:max(6px, env(safe-area-inset-top));right:max(6px, env(safe-area-inset-right));border:1px solid var(--line);background:var(--sidebar);border-radius:6px;gap:2px;padding:2px;display:flex;position:fixed}.mobile-controls button{width:28px;height:28px;color:var(--ink-soft);background:0 0;border:0;border-radius:4px;place-items:center;display:grid}.mobile-controls button:hover{background:var(--surface-hover)}}@media (prefers-reduced-motion:reduce){*,:before,:after{transition-duration:.01ms!important;animation-duration:.01ms!important}}@media (pointer:coarse){.app-shell.is-thread{top:var(--app-top,0px);height:var(--app-height,100dvh);min-height:0;position:fixed;left:0;right:0;overflow:hidden}.app-shell.is-thread .main-panel{height:100%;min-height:0;padding-left:env(safe-area-inset-left);padding-right:env(safe-area-inset-right)}html:has(.app-shell.is-thread),html:has(.app-shell.is-thread) body{overscroll-behavior:none}}.sidebar{border-right:1px solid var(--line);background:var(--sidebar);flex-direction:column;flex:0 0 280px;min-width:0;height:100dvh;display:flex;position:sticky;top:0}.sidebar.is-collapsed{flex-basis:44px}.sidebar-header{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;height:48px;padding:0 10px 0 16px;display:flex}.sidebar.is-collapsed .sidebar-header{justify-content:center;padding:0}.brand{letter-spacing:-.02em;align-items:center;gap:7px;font-size:12px;font-weight:700;display:inline-flex}.sidebar-collapse{width:26px;height:26px}.sidebar-actions{flex:none;align-items:center;gap:5px;margin:12px 8px 4px;display:flex}.sidebar-new-thread{border:1px solid var(--card-edge);background:var(--surface-raised);height:34px;box-shadow:var(--card-shadow);color:var(--ink);border-radius:7px;flex:none;justify-content:center;align-items:center;gap:6px;padding:0 10px;font-size:11px;font-weight:600;transition:background-color .2s,box-shadow .2s;display:inline-flex}.sidebar-new-thread span{transform:translateY(1px)}.sidebar-new-thread:hover{background:var(--card-hover);box-shadow:var(--card-shadow-hover)}.sidebar.is-collapsed .sidebar-actions{justify-content:center;margin:10px 0 0}.sidebar.is-collapsed .sidebar-new-thread{width:28px;height:28px;padding:0}.sidebar-open-worktree{border:1px solid var(--card-edge);background:var(--surface-raised);width:34px;height:34px;box-shadow:var(--card-shadow);color:var(--ink-soft);border-radius:7px;flex:none;place-items:center;padding:0;display:grid}.sidebar-open-worktree:hover:not(:disabled){background:var(--card-hover);box-shadow:var(--card-shadow-hover);color:var(--ink)}.sidebar.is-collapsed .sidebar-actions{flex-direction:column}.sidebar.is-collapsed .sidebar-open-worktree{width:28px;height:28px}.sidebar-view-switch{border:1px solid var(--line);background:var(--surface-muted);border-radius:7px;flex:none;gap:2px;width:70px;height:34px;margin-left:auto;padding:2px;display:flex}.sidebar-view-switch button{min-width:0;color:var(--ink-soft);background:0 0;border:1px solid #0000;border-radius:5px;flex:1;place-items:center;padding:0;display:grid}.sidebar-view-switch button:hover{color:var(--ink);background:var(--surface-hover)}.sidebar-view-switch button[aria-pressed=true]{border-color:var(--card-edge);background:var(--surface-raised);box-shadow:var(--card-shadow);color:var(--ink)}.thread-navigation{scrollbar-width:thin;flex:1;padding:8px;overflow-y:auto}.thread-row{min-height:60px;color:var(--ink-soft);border:1px solid #0000;border-radius:7px;grid-template-rows:17px 1fr;grid-template-columns:25px minmax(0,1fr) auto;column-gap:9px;padding:7px 10px;font-size:11.5px;transition:background-color .2s,border-color .2s,box-shadow .2s;display:grid}.thread-item{display:block;position:relative}.thread-item+.thread-item{margin-top:4px}.thread-archive{width:26px;height:26px;color:var(--ink-soft);opacity:0;pointer-events:none;background:0 0;border:0;border-radius:5px;place-items:center;padding:0;transition:opacity .15s,background-color .15s;display:grid;position:absolute;top:50%;right:9px;transform:translateY(-50%)}.thread-item:hover .thread-archive,.thread-archive:focus-visible{opacity:1;pointer-events:auto}.thread-archive:hover{background:var(--surface-hover);color:var(--ink)}.thread-item:hover .thread-time,.thread-item:focus-within .thread-time{visibility:hidden}@media (hover:none){.thread-archive{opacity:1;pointer-events:auto}.thread-item .thread-time{visibility:hidden}}.thread-menu-positioner{z-index:30}.thread-menu{border:1px solid var(--card-edge);background:var(--surface-raised);min-width:180px;color:var(--ink);box-shadow:var(--card-shadow-hover), 0 12px 32px #0000002e;border-radius:8px;outline:0;padding:4px}.thread-menu-item{cursor:pointer;border-radius:5px;outline:0;align-items:center;gap:8px;min-height:32px;padding:6px 8px;font-size:12px;display:flex}.thread-menu-item[data-highlighted]{background:var(--surface-hover)}.worktree-menu{width:260px;padding:0;overflow:hidden}.worktree-menu-list{max-height:min(320px, var(--available-height));padding:4px;overflow-y:auto}.worktree-menu-note{color:var(--ink-soft);margin:0;padding:8px;font-size:12px}.thread-menu-item>svg,.thread-menu-item>.thread-project{flex:none}.worktree-menu-name{overflow-wrap:anywhere;min-width:0;display:grid}.worktree-menu-name small{color:var(--ink-soft);font-size:10.5px}.worktree-menu-chevron{color:var(--ink-soft);margin-left:auto}.thread-menu-path{border-top:1px solid var(--line);max-width:240px;color:var(--ink-muted);text-overflow:ellipsis;white-space:nowrap;user-select:text;margin-top:4px;padding:6px 8px 4px;font-size:11px;overflow:hidden}.thread-menu-item.is-danger{color:var(--danger)}.archived-threads{border-top:1px solid var(--line);scrollbar-width:thin;flex:none;max-height:45%;padding:6px 8px;overflow-y:auto}.archived-threads .group-header{z-index:1;background:var(--sidebar);position:sticky;top:-6px}.group-count{font:10px var(--font-mono);color:var(--ink-muted);margin-left:2px}.archived-delete-all{height:26px;color:var(--ink-soft);background:0 0;border:1px solid #0000;border-radius:5px;flex:none;margin-right:4px;padding:0 8px;font-size:11px}.archived-delete-all:hover:not(:disabled){border-color:var(--line);background:var(--surface-hover);color:var(--danger)}.archived-delete-all:disabled{opacity:.6}.thread-avatar{grid-area:1/1/3;justify-content:center;align-self:center;align-items:center;width:25px;height:25px;display:inline-flex;position:relative}.thread-row:hover,.thread-row.is-current:hover{border-color:var(--card-edge);background:var(--card-hover);box-shadow:var(--card-shadow-hover);color:var(--ink)}.thread-row.needs-attention.status-blocked{border-color:color-mix(in srgb, var(--blocked) 45%, transparent)}.thread-row.needs-attention.status-done{border-color:color-mix(in srgb, var(--done) 45%, transparent)}.thread-row.is-current{border-color:var(--card-edge);background:var(--surface-raised);box-shadow:var(--card-shadow);color:var(--ink)}.thread-row.is-current:hover{background:var(--card-hover);box-shadow:var(--card-shadow-hover)}.thread-copy{flex-direction:column;grid-area:1/2/3;justify-content:center;gap:4px;min-width:0;display:flex}.thread-context{color:var(--ink-soft);align-items:center;gap:5px;font-size:10px;display:flex}.thread-name{white-space:nowrap;text-overflow:ellipsis;color:var(--ink);overflow:hidden}.thread-name-input{border:1px solid var(--focus-ring);background:var(--surface);width:100%;min-width:0;color:var(--ink);font:inherit;border-radius:4px;outline:0;margin:-2px -4px;padding:1px 4px}.thread-time{font:10px var(--font-mono);color:var(--ink-soft);grid-area:1/3;justify-self:end}.thread-status{background:var(--sidebar);color:var(--ink-soft);border-radius:50%;padding:1px;position:absolute;top:-5px;left:-5px}.thread-row.is-current .thread-status{background:var(--surface-raised)}.thread-row:hover .thread-status{background:var(--card-hover)}.thread-status.status-blocked{color:var(--blocked)}.thread-status.status-done{color:var(--done)}.thread-project{background:color-mix(in srgb, var(--project-color) 17%, transparent);width:17px;height:17px;color:var(--project-color);font:700 10px var(--font-mono);border-radius:3px;flex:none;place-items:center;display:grid;position:relative;overflow:hidden}.thread-project img{object-fit:contain;background:var(--surface-raised);opacity:0;width:100%;height:100%;position:absolute;inset:0}.thread-project.has-icon img{opacity:1}.group-header{align-items:center;min-width:0;display:flex}.group-title{min-width:0;color:var(--ink-soft);text-align:left;background:0 0;border:0;border-radius:5px;flex:1;align-items:center;gap:6px;padding:8px;font-size:11px;display:flex}.group-title:hover{color:var(--ink)}.thread-group{border-radius:7px}.thread-group:has(>.group-header:hover){background:var(--surface-hover)}.group-name{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.group-chevron{flex:none;justify-content:center;align-items:center;width:12px;height:17px;display:inline-flex}.thread-group+.thread-group{margin-top:8px}.sidebar-footer{border-top:1px solid var(--line);flex:none;align-items:center;gap:8px;padding:4px 12px 4px 4px;display:flex}.sidebar-settings{height:35px;color:var(--ink-soft);border-radius:6px;flex:1;align-items:center;gap:10px;padding:0 12px;font-size:11.5px;display:flex}.sidebar-palette{border:1px solid var(--line-strong);color:var(--ink-soft);background:0 0;border-radius:4px;justify-content:center;align-items:center;padding:3px 6px;display:inline-flex}.sidebar-palette:hover{color:var(--ink);background:var(--surface-hover)}.sidebar-palette kbd{font:10px/1 var(--font-mono)}.sidebar-settings:hover{color:var(--ink);background:var(--surface-hover)}.sidebar-empty{color:var(--ink-muted);padding:8px;font-size:11px}.sidebar-scrim,.mobile-close{display:none}@media (width<=760px){.sidebar,.sidebar.is-collapsed{z-index:11;flex-basis:auto;width:min(280px,86vw);transition:transform .2s;position:fixed;transform:translate(-100%)}.sidebar.is-mobile-open{transform:translate(0)}.sidebar.is-collapsed .sidebar-header{justify-content:space-between;padding:0 10px 0 16px}.sidebar.is-collapsed .thread-navigation,.sidebar.is-collapsed .sidebar-footer{display:flex}.mobile-close{display:inline-flex}.sidebar-collapse{display:none}.sidebar-scrim{z-index:10;background:#00000073;border:0;display:block;position:fixed;inset:0}}.thread-worktree{opacity:.7;flex:none}.form-page{width:min(520px,100%);margin:8vh auto 0;padding:0 20px 60px}.form-page h1{letter-spacing:-.03em;margin:0 0 26px;font-size:22px;font-weight:600}.form-error{color:#d16a64;font-size:12px}.settings-page section{margin-top:28px}.settings-page h2{margin:18px 0 8px;font-size:12px;font-weight:600}.settings-row{justify-content:space-between;align-items:center;gap:12px;min-height:44px;font-size:12px;display:flex}.settings-section-title{justify-content:space-between;align-items:center;display:flex}.settings-section-title button{color:var(--ink-soft);background:0 0;border:0;border-radius:5px;padding:6px 9px;font-size:11px}.settings-section-title button:hover{background:var(--surface-hover)}.settings-section-title button:hover,.shortcut-key:hover{color:var(--ink)}.shortcut-key{min-height:32px;color:var(--ink-soft);background:0 0;border:0;align-items:center;gap:4px;padding:3px 0;font-size:11px;display:inline-flex}.shortcut-key kbd{background:var(--surface-raised);min-width:25px;height:25px;box-shadow:var(--card-shadow);color:var(--ink-soft);font:11px var(--font-mono);border:0;border-radius:5px;place-items:center;padding:0 5px;display:inline-grid}.shortcut-key:hover kbd,.shortcut-key:focus-visible kbd{color:var(--ink);background:var(--card-hover);box-shadow:var(--card-shadow-hover)}.shortcut-key[data-recording=true]{box-shadow:inset 0 0 0 1px var(--focus-ring);color:var(--ink);border-radius:5px;padding:3px 8px}.settings-note{color:var(--ink-soft);margin:0 0 10px;font-size:11px}.theme-control{background:var(--surface-muted);border-radius:7px;gap:2px;padding:2px;display:flex}.theme-option{color:var(--ink-soft);text-transform:capitalize;background:0 0;border:0;border-radius:5px;align-items:center;gap:5px;padding:6px 8px;font-size:11px;display:flex}.theme-option:hover{color:var(--ink)}.theme-option.is-selected{background:var(--surface-raised);box-shadow:var(--card-shadow);color:var(--ink)}.theme-control--labels .theme-option{text-transform:none}.mobile-menu{display:none}@media (width<=760px){.form-page{margin-top:35px}.mobile-menu{color:var(--ink-soft);background:0 0;border:0;margin-bottom:30px;padding:4px 0;display:block}}.new-thread-page{flex-direction:column;justify-content:center;width:min(800px,100%);min-height:100dvh;margin:0 auto;padding:60px 20px;display:flex;position:relative}.new-thread-page h1{color:var(--ink);letter-spacing:-.035em;text-align:center;margin:0 0 26px;font-size:clamp(24px,2.8vw,32px);font-weight:500;line-height:1.25}.thread-composer{border:1px solid var(--line-strong);background:var(--surface);box-shadow:var(--card-shadow);border-radius:18px;transition:border-color .2s,box-shadow .2s}.thread-composer:focus-within{border-color:color-mix(in srgb, var(--focus-ring) 50%, var(--line-strong));box-shadow:var(--card-shadow-hover)}.workspace-composer{gap:12px;padding:18px;display:grid}.workspace-composer label{color:var(--ink-soft);gap:6px;font-size:12px;display:grid}.workspace-composer input{border:1px solid var(--line-strong);width:100%;color:var(--ink);background:0 0;border-radius:8px;padding:10px}.workspace-composer button{justify-self:end;padding:8px 12px}.workspace-composer .path-field{position:relative}.path-suggestions{z-index:5;border:1px solid var(--card-edge);background:var(--surface-raised);box-shadow:var(--card-shadow-hover);border-radius:8px;max-height:240px;margin:4px 0 0;padding:4px;list-style:none;position:absolute;top:100%;left:0;right:0;overflow-y:auto}.path-suggestions li{color:var(--ink);cursor:pointer;border-radius:5px;padding:6px 8px;font-size:13px}.path-suggestions li[data-highlighted]{background:var(--surface-hover)}.workspace-cancel{color:var(--ink-soft);background:0 0;border:0;border-radius:6px;align-self:center;padding:8px 12px}.workspace-cancel:hover{background:var(--surface-hover);color:var(--ink)}.thread-composer textarea{resize:vertical;width:100%;min-height:94px;max-height:45vh;color:var(--ink);background:0 0;border:0;outline:0;padding:17px 18px 0;font-size:14px;line-height:1.5;display:block}.thread-composer textarea::placeholder{color:var(--ink-muted)}.composer-toolbar{align-items:center;gap:10px;padding:9px 12px 12px;display:flex}.composer-selectors{flex-wrap:wrap;align-items:center;gap:3px;min-width:0;display:flex}.composer-actions{flex:none;align-items:center;gap:5px;margin-left:auto;display:inline-flex}.composer-preview-note{color:var(--ink-soft);text-align:center;margin:12px 0 0;font-size:11px}.worktree-option{width:30px;height:32px;color:var(--ink-soft);cursor:pointer;border-radius:6px;place-items:center;display:inline-grid;position:relative}.worktree-option:hover{color:var(--ink);background:var(--surface-hover)}.worktree-option:focus-within{outline:0}.worktree-option input{opacity:0;width:1px;height:1px;position:absolute}.worktree-option:has(input:not(:checked)){color:var(--ink-muted)}.worktree-option:has(input:checked){background:var(--surface-muted);color:var(--ink)}.composer-send{background:var(--action-blue);color:#fff;border:0;border-radius:50%;flex:none;place-items:center;width:36px;height:36px;display:inline-grid}.composer-send:hover:not(:disabled){filter:brightness(1.12)}.composer-send:disabled{background:var(--surface-active);color:var(--ink-muted);cursor:not-allowed}.new-thread-page .form-error{margin:12px 4px}@media (width<=760px){.new-thread-page{padding:60px 16px}.new-thread-page .mobile-menu{margin:0;position:absolute;top:16px;left:16px}}@media (width<=480px){.composer-toolbar{gap:6px;padding:8px}}.thread-composer.is-dropping{border-style:dashed;border-color:var(--focus-ring);box-shadow:var(--card-shadow-hover)}.composer-images{flex-wrap:wrap;gap:8px;margin:0;padding:14px 18px 0;list-style:none;display:flex}.composer-images li{border:1px solid var(--line-strong);background:var(--surface-raised);border-radius:9px;width:56px;height:56px;position:relative;overflow:hidden}.composer-images img{object-fit:cover;width:100%;height:100%;display:block}.composer-images button{background:color-mix(in srgb, var(--surface) 85%, transparent);width:18px;height:18px;color:var(--ink);cursor:pointer;opacity:0;border:0;border-radius:50%;place-items:center;padding:0;transition:opacity .15s;display:grid;position:absolute;top:3px;right:3px}.composer-images li:hover button,.composer-images button:focus-visible{opacity:1}.composer-images li.is-uploading{background:linear-gradient(100deg, var(--surface-raised) 30%, var(--surface-hover) 50%, var(--surface-raised) 70%) 0 0 / 200% 100%;animation:1.1s linear infinite composer-image-loading}@keyframes composer-image-loading{to{background-position:-200% 0}}@media (hover:none){.composer-images button{opacity:1}}.composer-select{min-width:0;max-width:min(220px,100%);height:32px;color:var(--ink-soft);background:0 0;border:1px solid #0000;border-radius:6px;justify-content:flex-start;align-items:center;gap:5px;padding:0 6px;font-size:11px;display:inline-flex}.composer-select:hover,.composer-select[data-popup-open]{border-color:var(--line);background:var(--surface-hover);color:var(--ink)}.composer-select-icon{flex:none;justify-content:center;align-items:center;display:inline-flex}.composer-select-icon .thread-project{width:14px;height:14px}.composer-select-label{white-space:nowrap;text-overflow:ellipsis;min-width:0;overflow:hidden}.composer-select-caret{flex:none;margin-left:8px}.composer-select-positioner{z-index:20}.composer-select-popup{width:min(280px, var(--available-width));max-height:var(--available-height);border:1px solid var(--card-edge);background:var(--surface-raised);color:var(--ink);box-shadow:var(--card-shadow-hover), 0 12px 32px #0000002e;border-radius:9px;overflow:hidden}.composer-select-search{border-bottom:1px solid var(--line);color:var(--ink-soft);align-items:center;gap:8px;padding:10px 12px;display:flex}.composer-select-search input{width:100%;min-width:0;color:var(--ink);background:0 0;border:0;outline:0;padding:0;font-size:16px;line-height:1.4}.composer-select-search input::placeholder{color:var(--ink-soft)}.composer-select-list{max-height:min(240px, calc(var(--available-height) - 50px));overscroll-behavior:contain;padding:5px;overflow-y:auto}.composer-select-option{cursor:pointer;border-radius:5px;align-items:center;gap:8px;min-height:36px;padding:8px;font-size:12px;display:flex}.composer-select-option[data-highlighted]{background:var(--surface-hover);outline:0}.composer-select-option[data-disabled]{opacity:.5;cursor:not-allowed}.composer-select-option-icon{flex:none;align-items:center;display:inline-flex}.composer-select-option-label{overflow-wrap:anywhere;flex:1;min-width:0}.composer-select-check{color:var(--ink-soft);flex:none;display:inline-flex}.composer-select-action{border:0;border-top:1px solid var(--line);width:calc(100% - 10px);min-height:36px;color:var(--ink);text-align:left;background:0 0;border-radius:5px;align-items:center;gap:8px;margin:2px 5px 5px;padding:8px;font-size:12px;display:flex}.composer-select-action:hover{background:var(--surface-hover)}.composer-select-empty{color:var(--ink-soft);font-size:12px}.composer-select-empty:not(:empty){padding:12px}@media (width>=761px){.composer-select-search input{font-size:12px}}@media (width<=480px){.composer-select-icon{display:none}}.pane-layout{background:var(--line-strong);grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;height:100dvh;min-height:0;display:grid}.pane-layout.single-pane{grid-template-columns:minmax(0,1fr)}.terminal-pane{background:var(--terminal-bg);min-width:0;min-height:0;overflow:hidden}.terminal-host{width:100%;height:100%}.terminal-host .xterm{height:100%;padding:12px 8px 8px 13px}.terminal-host .xterm-viewport{scrollbar-width:thin}@media (width<=760px){.pane-layout:not(.single-pane){grid-template-rows:repeat(2,minmax(0,1fr));grid-template-columns:minmax(0,1fr)}}.native-thread-layout{flex-direction:column;flex:1;min-width:0;min-height:0;display:flex}.native-pane-layout{flex:1;min-height:160px;position:relative}.native-pane-position{padding:2px;position:absolute}.terminal-peek{background:var(--surface-muted);color:var(--ink-soft);border:0;border-radius:6px;justify-content:center;align-items:center;gap:6px;padding:0;font-size:11px;transition:background-color .15s,color .15s;display:flex;position:absolute}.terminal-peek:hover,.terminal-peek:focus-visible{background:var(--surface-hover);color:var(--ink)}.terminal-peek.is-right{flex-direction:column;width:24px;top:2px;bottom:2px;right:2px}.terminal-peek.is-right span{writing-mode:vertical-rl}.terminal-peek.is-bottom{height:24px;bottom:2px;left:2px;right:2px}.native-pane-position .terminal-pane{flex-direction:column;height:100%;display:flex}.native-pane-layout.is-split .terminal-pane{outline:1px solid var(--line);outline-offset:-1px;border-radius:5px;transition:outline-color .15s}.native-pane-layout.is-split .terminal-pane.is-active{outline-color:color-mix(in srgb, var(--focus-ring) 15%, var(--line-strong))}.terminal-host{flex:1;height:auto;min-height:0;overflow:hidden}.terminal-pane{position:relative}.terminal-input-capture{opacity:.01;resize:none;border:0;width:2px;height:2px;padding:0;position:absolute;bottom:8px;left:12px}.terminal-status{clip-path:inset(50%);width:1px;height:1px;position:absolute;overflow:hidden}.terminal-control-conflict{z-index:10;border:1px solid var(--line-strong);background:var(--surface-raised);max-width:calc(100% - 16px);color:var(--ink-soft);box-shadow:var(--card-shadow);white-space:nowrap;border-radius:8px;align-items:center;gap:10px;padding:6px 10px;display:flex;position:absolute;top:8px;left:50%;transform:translate(-50%)}.terminal-control-conflict button{background:var(--surface-hover);color:var(--ink);border:0;border-radius:5px;padding:3px 7px}.adoption-picker{color:var(--ink-soft);gap:8px;padding:16px;font-size:13px;display:grid}.adoption-picker select{margin-left:8px}.runtime-notice{color:var(--ink-muted);padding:10px 16px;font-size:13px}.launch-progress{align-content:center;justify-items:start;gap:18px;width:min(520px,100%);height:100%;margin:0 auto;padding:0 20px;display:grid}.launch-progress h1{letter-spacing:-.03em;margin:0;font-size:22px;font-weight:600}.launch-prompt{border-left:2px solid var(--line-strong);color:var(--ink-soft);white-space:pre-wrap;overflow-wrap:anywhere;max-height:9em;margin:0;padding:10px 14px;font-size:13px;overflow:auto}.launch-steps{gap:10px;margin:0;padding:0;font-size:13px;list-style:none;display:grid}.launch-step{color:var(--ink-muted);align-items:center;gap:10px;display:flex}.launch-step.is-done{color:var(--ink-soft)}.launch-step.is-active{color:var(--ink)}.launch-step.is-active svg{animation:1s linear infinite launch-spin}.launch-step.is-failed{color:#d16a64}.launch-failure{color:var(--ink-soft);gap:10px;font-size:13px;display:grid}.launch-failure p{margin:0}.launch-retry{border:1px solid var(--line-strong);background:var(--surface-raised);color:var(--ink);border-radius:7px;justify-self:start;padding:7px 11px;font-size:12px;text-decoration:none}@keyframes launch-spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.launch-step.is-active svg{animation:none}}.terminal-host .xterm{font-variant-ligatures:none;font-feature-settings:"liga" 0, "calt" 0}.terminal-drop-overlay{z-index:15;border:1.5px dashed var(--line-strong);background:color-mix(in srgb, var(--terminal-bg) 82%, transparent);color:var(--ink);pointer-events:none;border-radius:10px;place-content:center;justify-items:center;gap:8px;font-size:13px;display:grid;position:absolute;inset:8px}.terminal-attach-notice{z-index:16;border:1px solid var(--line-strong);background:var(--surface-raised);max-width:calc(100% - 28px);box-shadow:var(--card-shadow);color:var(--ink-soft);border-radius:8px;padding:6px 10px;font-size:12px;position:absolute;bottom:12px;right:14px}.terminal-attach-notice.is-error{color:#d16a64}.terminal-key-bar{padding:6px 8px calc(6px + env(safe-area-inset-bottom));border-top:1px solid var(--line);background:var(--surface-muted);flex:none;gap:6px;display:flex}.terminal-key-bar button{background:var(--surface-raised);min-height:38px;box-shadow:var(--card-shadow);color:var(--ink);touch-action:manipulation;-webkit-user-select:none;user-select:none;border:0;border-radius:8px;flex:1;place-items:center;font-size:13px;display:inline-grid}.terminal-key-bar button.is-armed{background:var(--action-blue);color:#fff}.palette-overlay{z-index:30;background:#0000006e;justify-content:center;align-items:flex-start;padding:min(16vh,140px) 16px 16px;display:flex;position:fixed;inset:0}.palette{border:1px solid var(--card-edge);background:var(--surface);border-radius:9px;width:min(520px,100%);overflow:hidden;box-shadow:inset 0 1px 2px #ffffff1f,0 16px 60px #00000038}.palette>input{border:0;border-bottom:1px solid var(--line);width:100%;height:48px;color:var(--ink);background:0 0;outline:0;padding:0 16px;font-size:13px;display:block}.palette>input::placeholder{color:var(--ink-muted)}.palette-results{max-height:min(400px,55vh);padding:6px;overflow-y:auto}.palette-results button{width:100%;min-height:36px;color:var(--ink);text-align:left;background:0 0;border:1px solid #0000;border-radius:6px;align-items:center;gap:10px;padding:0 10px;font-size:12px;transition:background-color .2s,border-color .2s,box-shadow .2s;display:flex}.palette-icon{width:22px;height:22px;color:var(--ink-soft);flex:none;justify-content:center;align-items:center;display:inline-flex}.palette-label{white-space:nowrap;text-overflow:ellipsis;flex:1;overflow:hidden}.palette-results button:hover,.palette-results button.is-selected{border-color:var(--card-edge);background:var(--surface-raised);box-shadow:var(--card-shadow)}.palette-results button:hover{background:var(--card-hover);box-shadow:var(--card-shadow-hover)}.palette-results button small{color:var(--ink-soft);white-space:nowrap;font-size:10px}.palette-results button kbd{border:1px solid var(--line-strong);min-width:34px;color:var(--ink-soft);font:10px var(--font-mono);text-align:center;white-space:nowrap;border-radius:4px;margin-left:6px;padding:3px 5px}.palette-results p{color:var(--ink-muted);padding:4px 10px}.palette-help{border-top:1px solid var(--line);color:var(--ink-soft);font:10px var(--font-mono);gap:14px;padding:9px 14px;display:flex}.dialog-backdrop{z-index:40;background:#0000006e;position:fixed;inset:0}.dialog{z-index:41;border:1px solid var(--card-edge);background:var(--surface);width:min(400px,100vw - 32px);color:var(--ink);border-radius:10px;outline:0;padding:18px;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);box-shadow:inset 0 1px 2px #ffffff1f,0 16px 60px #00000038}.dialog-message{margin:0 0 16px;font-size:13px;line-height:1.5}.dialog-actions{justify-content:flex-end;gap:8px;display:flex}.dialog-button{border:1px solid var(--line-strong);background:var(--surface-raised);min-height:32px;color:var(--ink);cursor:pointer;border-radius:6px;padding:0 12px;font-size:12px}.dialog-button:hover{background:var(--surface-hover)}.dialog-button.is-primary{background:var(--action-blue);color:#fff;border-color:#0000}.dialog-button.is-danger{background:var(--danger)}.pairing-title{margin:0 0 6px;font-size:14px;font-weight:600}.pairing-code{font:700 40px/1 var(--font-mono);letter-spacing:.18em;text-align:center;text-indent:.18em;margin:0 0 16px}.pairing-invite .dialog-message code{font-family:var(--font-mono)}.qr-code{aspect-ratio:1;border-radius:8px;width:min(240px,100%);margin:0 auto 14px;display:block}.pairing-token{justify-content:center;align-items:center;gap:8px;margin:0 0 6px;display:flex}.pairing-token code{font:600 14px var(--font-mono);letter-spacing:.08em;user-select:all}.pairing-token .dialog-button{place-items:center;min-width:32px;padding:0;display:inline-grid}.device-tag{background:var(--accent-soft);color:var(--accent);border-radius:4px;margin-left:8px;padding:1px 6px;font-size:10px;font-style:normal}.device-meta{color:var(--ink-muted);font-size:11px;display:block}.crash-report{z-index:100;color:#7a1f1a;max-height:60dvh;font:11px/1.4 var(--font-mono);white-space:pre-wrap;word-break:break-word;background:#fff0ef;margin:0;padding:12px 16px;position:fixed;inset:0 0 auto;overflow:auto}.pairing-invite .theme-control{flex-wrap:wrap;justify-content:center;margin:0 0 12px}.pairing-invite .theme-option{white-space:nowrap}.settings-subtitle{color:var(--ink-soft);margin:16px 0 4px;font-size:11px;font-weight:500}.activity-row{color:var(--ink-muted);justify-content:space-between;gap:12px;margin:0;padding:4px 0;font-size:11px;display:flex}.activity-row time{flex:none}.perf-overlay{z-index:40;color:#d7ffd7;font-variant-numeric:tabular-nums;pointer-events:none;background:#000000c7;border-radius:6px;padding:4px 8px;font:11px/1.5 ui-monospace,monospace;position:fixed;bottom:8px;right:8px}.tooltip-positioner{z-index:40}.tooltip-popup{border:1px solid var(--line);background:var(--surface-raised);box-shadow:var(--card-shadow);color:var(--ink);white-space:nowrap;border-radius:6px;padding:5px 8px;font-size:11px;font-weight:500;transition:opacity .16s,transform .16s}.tooltip-popup[data-starting-style],.tooltip-popup[data-ending-style]{opacity:0;transform:translateY(3px)}::view-transition-group(root),::view-transition-old(root),::view-transition-new(root){mix-blend-mode:normal;animation:none}.astro-route-announcer{clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;width:1px;height:1px;position:absolute;top:0;left:0;overflow:hidden}.xterm{cursor:text;-webkit-user-select:none;-ms-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:underline double}.xterm-underline-3{text-decoration:underline wavy}.xterm-underline-4{text-decoration:underline dotted}.xterm-underline-5{text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}