@omniterm/host 0.2.10

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 ADDED
@@ -0,0 +1,118 @@
1
+ # OmniTerm
2
+
3
+ Your agent terminals, accessible everywhere.
4
+
5
+ ## Why OmniTerm?
6
+
7
+ - **Built for AI agents** — run Claude Code, Codex, or any CLI agent in a terminal
8
+ - **Always running** — 24/7, persist across browser closes, network drops, and device switches
9
+ - **Work from anywhere** — start on your desktop / cloud, continue from your iPad, smartphone
10
+ - **Easy setup** — one command to start, accessible through the browser. No SSH tunnels
11
+ - **Lightweight** — under 1MB package, loads in under 2 seconds
12
+
13
+ ## Quick Start
14
+
15
+ Install dependencies first:
16
+
17
+ ```bash
18
+ # macOS
19
+ brew install tmux ttyd
20
+
21
+ # Ubuntu/Debian
22
+ sudo apt install tmux
23
+ sudo snap install ttyd --classic
24
+ ```
25
+
26
+ Then install and run:
27
+
28
+ ```bash
29
+ npm install -g omniterm
30
+ omniterm
31
+ # Open http://localhost:17717
32
+ ```
33
+
34
+ Custom port:
35
+
36
+ ```bash
37
+ omniterm --port 8080
38
+ ```
39
+
40
+ OmniTerm uses ports 7700-7799 internally for terminal sessions. If this range conflicts, override it:
41
+
42
+ ```bash
43
+ omniterm --ttyd-ports 8800-8899
44
+ ```
45
+
46
+ Run `omniterm --help` for all options.
47
+
48
+ ## What You Get
49
+
50
+ ### Terminals
51
+
52
+ - Multiple tabs, each containing one or more terminal panes
53
+ - Split terminals side-by-side or top/bottom within a tab
54
+ - Drag to reorder tabs, double-click to rename
55
+ - Sessions survive browser disconnects and server restarts
56
+ - Mouse wheel scrollback, native text selection
57
+
58
+ ### Workspaces
59
+
60
+ - Switch between git repos, worktrees, and directories
61
+ - Clone repos or browse the server filesystem to add new workspaces
62
+ - Create and manage git worktrees with one click
63
+ - Discovers existing tmux sessions automatically — start a session from SSH, see it in the browser
64
+
65
+ ### File Explorer & Editor
66
+
67
+ - Browse files, edit with syntax highlighting and Cmd+S save
68
+ - Supports TypeScript, Python, JSON, Markdown, CSS, HTML
69
+ - Auto-refreshes file tree and editor content on window focus
70
+ - Unsaved changes protection when switching files
71
+
72
+ ### Mobile & Desktop
73
+
74
+ - Full terminal experience on phones and tablets
75
+ - Add to home screen for a native app feel (PWA)
76
+ - Desktop: overlay panels for workspaces and files
77
+ - Mobile: full-screen views with touch navigation
78
+
79
+ ## Use Cases
80
+
81
+ ### AI Agent Fleet
82
+
83
+ Run multiple AI agents in parallel, each in its own workspace. Split terminals to monitor two agents side by side. Review their output in the editor.
84
+
85
+ ### Remote Development
86
+
87
+ Code on a powerful cloud server from any device. Start a build on your desktop, check results from your iPad. The terminal never stops.
88
+
89
+ ### Pair Programming with AI
90
+
91
+ One pane for your agent, another for your build server, the editor on the side. The agent writes code, you review and edit — all in one browser tab.
92
+
93
+ ## Remote Access
94
+
95
+ Access via SSH tunnel or VPN:
96
+
97
+ ```bash
98
+ ssh -L 17717:localhost:17717 your-server
99
+ open http://localhost:17717
100
+ ```
101
+
102
+ Or directly via Tailscale:
103
+
104
+ ```bash
105
+ open http://your-server.tailnet:17717
106
+ ```
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ pnpm install
112
+ pnpm run dev
113
+ # Open http://localhost:17717
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * omniterm-browser — system browser shim for omniterm tabs.
4
+ *
5
+ * When an agent inside an omniterm tab opens a URL (gcloud auth login,
6
+ * `gh auth login --web`, npm OAuth flows, anything that respects $BROWSER
7
+ * or xdg-open), this script:
8
+ *
9
+ * 1. Launches Chrome against a DEDICATED user-data-dir (NOT the user's
10
+ * personal profile) with --remote-debugging-port=0 the first time.
11
+ * 2. Reads `<UDD>/DevToolsActivePort` to discover the CDP WebSocket URL.
12
+ * 3. POSTs that URL to the tab's registry (OMNITERM_BROWSER_REGISTRY_URL),
13
+ * where the omniterm UI picks it up and offers a remote DevTools view.
14
+ *
15
+ * Subsequent invocations (same UDD, Chrome still alive) defer the URL into
16
+ * the existing instance via Chrome's singleton-IPC handoff — no second
17
+ * process is spawned, but we still POST the existing CDP URL so the calling
18
+ * tab's panel surfaces it. The registry de-dupes by cdpUrl, so re-POSTing
19
+ * is harmless within a tab and gives each cross-tab caller its own entry.
20
+ *
21
+ * Why a dedicated UDD: keeps real-account cookies / passwords out of any
22
+ * profile that has CDP exposed. Personal Chrome on the laptop is never
23
+ * touched by this wrapper.
24
+ *
25
+ * No OMNITERM_BROWSER_REGISTRY_URL? We still launch Chrome (so the agent's flow
26
+ * doesn't hang), but warn — the user won't see the browser remotely.
27
+ */
28
+
29
+ import { spawn, execFileSync } from 'node:child_process';
30
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, unlinkSync } from 'node:fs';
31
+ import { homedir, platform } from 'node:os';
32
+ import path from 'node:path';
33
+
34
+ const URL_ARG = process.argv[2] || 'about:blank';
35
+ const REGISTRY_URL = (process.env.OMNITERM_BROWSER_REGISTRY_URL || '').replace(/\/$/, '');
36
+ const UDD =
37
+ process.env.OMNITERM_BROWSER_UDD || path.join(homedir(), '.omniterm', 'browser-profile');
38
+ const HEADLESS = process.env.OMNITERM_BROWSER_HEADLESS === '1';
39
+
40
+ function findChromeBinary() {
41
+ if (process.env.OMNITERM_CHROME_PATH) return process.env.OMNITERM_CHROME_PATH;
42
+ const candidates =
43
+ platform() === 'darwin'
44
+ ? [
45
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
46
+ '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
47
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
48
+ ]
49
+ : [
50
+ '/usr/bin/google-chrome',
51
+ '/usr/bin/google-chrome-stable',
52
+ '/usr/bin/chromium',
53
+ '/usr/bin/chromium-browser',
54
+ '/snap/bin/chromium',
55
+ ];
56
+ for (const p of candidates) if (existsSync(p)) return p;
57
+ for (const name of ['google-chrome-stable', 'google-chrome', 'chromium-browser', 'chromium']) {
58
+ try {
59
+ const found = execFileSync('which', [name], { encoding: 'utf-8' }).trim();
60
+ if (found) return found;
61
+ } catch {}
62
+ }
63
+ throw new Error('No Chrome/Chromium binary found. Set OMNITERM_CHROME_PATH=/path/to/chrome.');
64
+ }
65
+
66
+ // Chrome writes a SingletonLock symlink in the UDD when it owns the profile.
67
+ // The symlink TARGET is a marker (`<hostname>-<pid>`), not a real file path —
68
+ // so `existsSync` follows the symlink, fails to find the marker, and returns
69
+ // false even when the lock exists. We must lstat the symlink itself.
70
+ //
71
+ // We ALSO verify the encoded pid is actually alive: if Chrome was SIGKILL'd
72
+ // (or otherwise crashed), the lock survives but is stale. Trusting a stale
73
+ // lock makes the warm path spawn Chrome without `--remote-debugging-port`
74
+ // and the spawned Chrome silently becomes the new owner with no CDP — so
75
+ // every subsequent registration points at a port nothing is listening on.
76
+ // When the lock is stale, we remove it and signal the caller to cold-start.
77
+ function readSingleton() {
78
+ const lockPath = path.join(UDD, 'SingletonLock');
79
+ let isSymlink = false;
80
+ try {
81
+ isSymlink = lstatSync(lockPath).isSymbolicLink();
82
+ } catch {
83
+ return null;
84
+ }
85
+ if (!isSymlink) return null;
86
+ let pid;
87
+ try {
88
+ const target = readlinkSync(lockPath);
89
+ const m = target.match(/-(\d+)$/);
90
+ if (m) pid = parseInt(m[1], 10);
91
+ } catch {
92
+ return null;
93
+ }
94
+ if (pid === undefined) return null;
95
+ // Liveness check — if Chrome's gone, the lock is a corpse. Distinguish
96
+ // ESRCH (pid truly doesn't exist) from EPERM (exists but owned by another
97
+ // uid — common in containers / rootless setups). Treat EPERM as alive,
98
+ // since something is holding the pid; only unlink on ESRCH.
99
+ try {
100
+ process.kill(pid, 0);
101
+ } catch (err) {
102
+ if (err && err.code !== 'ESRCH') return { pid };
103
+ try {
104
+ unlinkSync(lockPath);
105
+ } catch {}
106
+ return null;
107
+ }
108
+ return { pid };
109
+ }
110
+
111
+ async function readDevToolsActivePort(timeoutMs = 30_000) {
112
+ const filePath = path.join(UDD, 'DevToolsActivePort');
113
+ const deadline = Date.now() + timeoutMs;
114
+ let lastErr = '';
115
+ while (Date.now() < deadline) {
116
+ try {
117
+ const raw = readFileSync(filePath, 'utf-8').trim();
118
+ const [portLine, wsPath] = raw.split('\n');
119
+ const port = parseInt(portLine, 10);
120
+ if (
121
+ Number.isFinite(port) &&
122
+ port > 0 &&
123
+ typeof wsPath === 'string' &&
124
+ wsPath.startsWith('/')
125
+ ) {
126
+ return { port, wsPath };
127
+ }
128
+ lastErr = `unexpected contents: ${JSON.stringify(raw)}`;
129
+ } catch (err) {
130
+ lastErr = err && err.code === 'ENOENT' ? 'file not yet written' : String(err);
131
+ }
132
+ await new Promise((r) => setTimeout(r, 100));
133
+ }
134
+ throw new Error(`Timed out waiting for ${filePath} (${lastErr})`);
135
+ }
136
+
137
+ async function postRegistration(cdpUrl, pid) {
138
+ if (!REGISTRY_URL) {
139
+ console.error(
140
+ '[omniterm-browser] OMNITERM_BROWSER_REGISTRY_URL not set — browser launched but not registered. ' +
141
+ 'Run inside an omniterm tab to get remote access.',
142
+ );
143
+ return;
144
+ }
145
+ try {
146
+ const res = await fetch(`${REGISTRY_URL}/browsers`, {
147
+ method: 'POST',
148
+ headers: { 'content-type': 'application/json' },
149
+ body: JSON.stringify({ cdpUrl, label: 'omniterm-browser', pid }),
150
+ });
151
+ if (!res.ok) {
152
+ console.error(`[omniterm-browser] registry POST failed: ${res.status} ${res.statusText}`);
153
+ return;
154
+ }
155
+ const data = await res.json();
156
+ console.error(
157
+ `[omniterm-browser] registered ${data.deduped ? '(deduped) ' : ''}id=${data.id} cdp=${cdpUrl}`,
158
+ );
159
+ } catch (err) {
160
+ console.error(`[omniterm-browser] registry POST error: ${String(err)}`);
161
+ }
162
+ }
163
+
164
+ function chromeArgs(includeDebugFlags) {
165
+ const args = [`--user-data-dir=${UDD}`];
166
+ if (includeDebugFlags) {
167
+ args.push(
168
+ '--remote-debugging-port=0',
169
+ // Restrict to a single explicit loopback Origin instead of `*`. The
170
+ // omniterm WS proxy SETS this Origin on every forwarded handshake
171
+ // (see handleCdpUpgrade in tabRegistry.ts), so legitimate traffic
172
+ // matches. A malicious page in the user's real browser trying to
173
+ // drive-by ws://127.0.0.1:<cdp-port> would carry its own Origin
174
+ // (e.g. https://evil.com) and be rejected.
175
+ '--remote-allow-origins=http://127.0.0.1',
176
+ );
177
+ }
178
+ if (HEADLESS) args.push('--headless=new');
179
+ args.push(URL_ARG);
180
+ return args;
181
+ }
182
+
183
+ async function main() {
184
+ mkdirSync(UDD, { recursive: true });
185
+ const chromeBinary = findChromeBinary();
186
+ const existing = readSingleton();
187
+
188
+ if (existing) {
189
+ // Warm path: hand the URL off via Chrome's singleton IPC. The first
190
+ // process Chrome sees with this UDD owns the lock; subsequent launches
191
+ // (us, right now) just deliver the URL as a new tab in the existing
192
+ // instance. CDP stays on whatever the cold-start invocation enabled.
193
+ spawn(chromeBinary, chromeArgs(false), {
194
+ detached: true,
195
+ stdio: 'ignore',
196
+ }).unref();
197
+ const { port, wsPath } = await readDevToolsActivePort();
198
+ await postRegistration(`ws://127.0.0.1:${port}${wsPath}`, existing.pid);
199
+ return;
200
+ }
201
+
202
+ // Cold path: own the UDD, enable CDP, then register. Chrome doesn't
203
+ // delete DevToolsActivePort on exit, so a stale file from a previous
204
+ // Chrome would otherwise be read instantaneously by readDevToolsActivePort
205
+ // before the new Chrome rewrites it — registering the stale port and
206
+ // making the DevTools view fail to connect. Delete first so the poll
207
+ // is forced to wait for the new contents.
208
+ try {
209
+ unlinkSync(path.join(UDD, 'DevToolsActivePort'));
210
+ } catch {}
211
+ const child = spawn(chromeBinary, chromeArgs(true), {
212
+ detached: true,
213
+ stdio: 'ignore',
214
+ });
215
+ child.unref();
216
+ const { port, wsPath } = await readDevToolsActivePort();
217
+ await postRegistration(`ws://127.0.0.1:${port}${wsPath}`, child.pid);
218
+ }
219
+
220
+ main().catch((err) => {
221
+ console.error(`[omniterm-browser] fatal: ${String(err)}`);
222
+ process.exit(1);
223
+ });
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * omniterm CLI launcher.
4
+ *
5
+ * Parses CLI flags, verifies ttyd + tmux are installed, and spawns the
6
+ * bundled server entry (standalone/server/server.js). Mirrors the
7
+ * predecessor pattern from the original omniterm app — bin is a thin
8
+ * launcher; standalone/ holds the compiled artifact.
9
+ */
10
+
11
+ import { execFileSync, spawn } from 'node:child_process';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const args = process.argv.slice(2);
18
+
19
+ if (args.includes('--version') || args.includes('-v')) {
20
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
21
+ console.log(pkg.version);
22
+ process.exit(0);
23
+ }
24
+
25
+ if (args.includes('--help') || args.includes('-h')) {
26
+ console.log(`Usage: omniterm [options]
27
+
28
+ Options:
29
+ --port <port> Bind port (default: 17717, env: OMNITERM_PORT)
30
+ --host <host> Bind address (default: 0.0.0.0, env: OMNITERM_HOST)
31
+ --ttyd-ports <range> Internal port range for terminals (default: 7700-7799)
32
+ --plugin <path|name> Load a plugin by path or package name (repeatable)
33
+ --version, -v Print version and exit
34
+ --help, -h Show this help
35
+ `);
36
+ process.exit(0);
37
+ }
38
+
39
+ function flag(name) {
40
+ const i = args.indexOf(name);
41
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
42
+ }
43
+
44
+ // Collect every `--name <value>` pair (repeatable flags), preserving order.
45
+ // Forwarded verbatim to the server entry, which parses them.
46
+ function collectFlag(name) {
47
+ const out = [];
48
+ for (let i = 0; i < args.length; i++) {
49
+ if (args[i] === name && i + 1 < args.length) out.push(name, args[++i]);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function parsePort(raw, label) {
55
+ const n = parseInt(raw, 10);
56
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
57
+ console.error(`[omniterm] Invalid ${label}: ${JSON.stringify(raw)} (expected 1-65535)`);
58
+ process.exit(1);
59
+ }
60
+ return n;
61
+ }
62
+
63
+ function parseTtydRange(raw) {
64
+ const m = raw.match(/^(\d+)-(\d+)$/);
65
+ if (!m) {
66
+ console.error(
67
+ `[omniterm] Invalid --ttyd-ports: ${JSON.stringify(raw)} (expected MIN-MAX, e.g. 7700-7799)`,
68
+ );
69
+ process.exit(1);
70
+ }
71
+ const min = parsePort(m[1], 'ttyd-ports MIN');
72
+ const max = parsePort(m[2], 'ttyd-ports MAX');
73
+ if (max < min) {
74
+ console.error(`[omniterm] Invalid --ttyd-ports: MIN (${min}) > MAX (${max})`);
75
+ process.exit(1);
76
+ }
77
+ return [String(min), String(max)];
78
+ }
79
+
80
+ const PORT = parsePort(flag('--port') ?? process.env.OMNITERM_PORT ?? '17717', '--port');
81
+ const HOST = flag('--host') ?? process.env.OMNITERM_HOST ?? '0.0.0.0';
82
+ const ttydRangeRaw = flag('--ttyd-ports');
83
+ const [TTYD_MIN, TTYD_MAX] = ttydRangeRaw
84
+ ? parseTtydRange(ttydRangeRaw)
85
+ : [process.env.OMNITERM_TTYD_PORT_MIN ?? '7700', process.env.OMNITERM_TTYD_PORT_MAX ?? '7799'];
86
+
87
+ const serverEntry = path.join(__dirname, '..', 'standalone', 'server', 'server.js');
88
+ const clientEntry = path.join(__dirname, '..', 'standalone', 'client', 'index.html');
89
+
90
+ if (!existsSync(serverEntry) || !existsSync(clientEntry)) {
91
+ console.error(
92
+ [
93
+ '[omniterm] This installation is missing the bundled standalone app.',
94
+ ` server: ${serverEntry}`,
95
+ ` client: ${clientEntry}`,
96
+ 'Reinstall the published package with `npm install -g omniterm@latest`.',
97
+ 'When developing from the monorepo, run `pnpm --filter omniterm build` before starting the CLI.',
98
+ ].join('\n'),
99
+ );
100
+ process.exit(1);
101
+ }
102
+
103
+ for (const cmd of ['ttyd', 'tmux']) {
104
+ try {
105
+ execFileSync('which', [cmd], { stdio: 'ignore' });
106
+ } catch {
107
+ console.error(
108
+ `[omniterm] Error: ${cmd} is not installed. Install it (e.g., 'brew install ${cmd}') and retry.`,
109
+ );
110
+ process.exit(1);
111
+ }
112
+ }
113
+
114
+ console.log(`[omniterm] Starting on http://${HOST}:${PORT}`);
115
+
116
+ const pluginArgs = collectFlag('--plugin');
117
+
118
+ const child = spawn(process.execPath, [serverEntry, ...pluginArgs], {
119
+ env: {
120
+ ...process.env,
121
+ OMNITERM_PORT: String(PORT),
122
+ OMNITERM_HOST: HOST,
123
+ OMNITERM_TTYD_PORT_MIN: TTYD_MIN,
124
+ OMNITERM_TTYD_PORT_MAX: TTYD_MAX,
125
+ NODE_ENV: process.env.NODE_ENV ?? 'production',
126
+ },
127
+ stdio: 'inherit',
128
+ });
129
+
130
+ // On Ctrl-C / SIGTERM, forward to the child and WAIT for it to exit before
131
+ // we exit ourselves — otherwise the child's own cleanup (killing ttyd/tmux
132
+ // subprocesses) gets cut short and orphans pile up. Belt: a 5s force-kill
133
+ // timer in case the child hangs.
134
+ let exiting = false;
135
+ const cleanup = (signal) => {
136
+ if (exiting) return;
137
+ exiting = true;
138
+ child.kill(signal);
139
+ const force = setTimeout(() => {
140
+ try {
141
+ child.kill('SIGKILL');
142
+ } catch {}
143
+ }, 5000);
144
+ child.once('exit', () => {
145
+ clearTimeout(force);
146
+ process.exit(0);
147
+ });
148
+ };
149
+ process.on('SIGINT', () => cleanup('SIGINT'));
150
+ process.on('SIGTERM', () => cleanup('SIGTERM'));
151
+
152
+ child.on('exit', (code) => {
153
+ if (exiting) return; // already handled by cleanup() above
154
+ if (code !== 0) console.error(`[omniterm] Server exited with code ${code}`);
155
+ process.exit(code ?? 1);
156
+ });
package/bin/xdg-open ADDED
@@ -0,0 +1,6 @@
1
+ #!/bin/sh
2
+ # xdg-open shim — present in PATH only inside omniterm tmux sessions
3
+ # (sessions.ts prepends our bin dir). Outside an omniterm tab the system
4
+ # /usr/bin/xdg-open is unaffected. Forwards every URL/file argument to
5
+ # omniterm-browser, which handles registration with the tab's registry.
6
+ exec "$(dirname "$0")/omniterm-browser.js" "$@"
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@omniterm/host",
3
+ "version": "0.2.10",
4
+ "description": "omniterm — a generic, browser-based dev host: persistent terminals, a browser-view panel, workspace management, and a runtime plugin API. CLI: omniterm.",
5
+ "type": "module",
6
+ "bin": {
7
+ "omniterm": "bin/omniterm.js",
8
+ "omniterm-browser": "bin/omniterm-browser.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "standalone/"
13
+ ],
14
+ "dependencies": {
15
+ "@shiplightai/devtools-assets": "1.0.0",
16
+ "express": "^5.2.1",
17
+ "http-proxy": "^1.18.1",
18
+ "ignore": "^7.0.5",
19
+ "marked": "^18.0.3"
20
+ },
21
+ "devDependencies": {
22
+ "@types/express": "^5.0.6",
23
+ "@types/http-proxy": "^1.17.17",
24
+ "@types/node": "^24.12.4",
25
+ "tsup": "^8.3.5",
26
+ "tsx": "^4.21.0",
27
+ "typescript": "^5.8.0",
28
+ "@omniterm/core": "0.1.6"
29
+ },
30
+ "publishConfig": {
31
+ "registry": "https://registry.npmjs.org",
32
+ "access": "public"
33
+ },
34
+ "engines": {
35
+ "node": ">=24"
36
+ },
37
+ "scripts": {
38
+ "dev": "tsx watch src/server.ts",
39
+ "start": "tsx src/server.ts",
40
+ "build": "./scripts/package.sh",
41
+ "postinstall": "chmod +x bin/xdg-open 2>/dev/null || true",
42
+ "typecheck": "tsc --noEmit"
43
+ }
44
+ }
@@ -0,0 +1 @@
1
+ *,:before,:after{box-sizing:border-box;margin:0;padding:0}:root{--bg:#1e1e1e;--bg-secondary:#252526;--bg-tertiary:#2d2d2d;--border:#3e3e3e;--text:#ccc;--text-muted:#999;--text-bright:#fff;--accent:#094771;--accent-hover:#0d5a8e;--danger:#da3633;--danger-hover:#f85149;--warning:#d29922;--link:#58a6ff;--tab-active:#1e1e1e;--tab-inactive:#2d2d2d;--sidebar-width:260px;--tab-height:36px}html,body,#root{background:var(--bg);width:100%;height:100dvh;color:var(--text);font-family:SF Mono,Fira Code,Cascadia Code,Menlo,Monaco,Courier New,monospace;font-size:13px;line-height:1.5;overflow:hidden}button{font-family:inherit;font-size:inherit;cursor:pointer;color:inherit;background:0 0;border:none}input{font-family:inherit;font-size:inherit;color:inherit;background:var(--bg);border:1px solid var(--border);border-radius:3px;outline:none;padding:4px 8px}input:focus{border-color:var(--link)}button:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--link);outline-offset:-2px}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}._panel_160pl_1{background:var(--bg-secondary);border-right:1px solid var(--border);-webkit-user-select:none;user-select:none;flex-direction:column;flex-shrink:0;height:100%;display:flex;position:relative}._header_160pl_13{border-bottom:1px solid var(--border);flex-shrink:0;align-items:center;gap:4px;padding:8px;display:flex}._title_160pl_22{letter-spacing:.5px;color:var(--text-muted);cursor:pointer;font-size:11px;font-weight:600}._title_160pl_22:hover{color:var(--text)}._spacer_160pl_34{flex:1}._iconBtn_160pl_38{color:var(--text-muted);cursor:pointer;background:0 0;border:none;border-radius:3px;justify-content:center;align-items:center;padding:2px;display:flex}._iconBtn_160pl_38:hover{color:var(--text);background:var(--bg-tertiary)}._panel_160pl_1{flex-direction:column;min-height:0;display:flex;overflow:hidden}._panelScroll_160pl_63{flex:1;padding:4px 0;overflow:auto}._divider_160pl_70{cursor:row-resize;touch-action:none;border-top:1px solid var(--border);border-bottom:1px solid var(--border);background:var(--bg-tertiary);flex-shrink:0;align-items:center;height:24px;padding:0 8px;display:flex}._divider_160pl_70:hover{background:var(--bg)}._dividerLabel_160pl_87{letter-spacing:.5px;color:var(--text-muted);pointer-events:none;font-size:11px;font-weight:600}._group_160pl_96{margin-bottom:2px}._groupHeader_160pl_100{align-items:center;gap:4px;padding:4px 8px;display:flex}._groupHeader_160pl_100:hover ._groupActions_160pl_107{opacity:1}._expandBtn_160pl_111{width:20px;height:22px;color:var(--text-muted);cursor:pointer;background:0 0;border:none;border-radius:3px;flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}._expandBtn_160pl_111:hover{color:var(--text);background:var(--bg-tertiary)}._groupName_160pl_130{color:var(--text);cursor:pointer;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:12px;font-weight:600;overflow:hidden}._groupActions_160pl_107{opacity:0;gap:2px;transition:opacity .15s;display:flex}._actionBtn_160pl_148{color:var(--text-muted);cursor:pointer;background:0 0;border:none;border-radius:3px;padding:0 4px;font-size:14px;line-height:1}._actionBtn_160pl_148:hover{color:var(--text);background:var(--bg-tertiary)}._itemList_160pl_165{padding:0}._item_160pl_165{cursor:pointer;align-items:center;gap:6px;padding:3px 12px 3px 44px;font-size:12px;display:flex}._item_160pl_165:hover{background:var(--bg-tertiary)}._item_160pl_165:hover ._deleteBtn_160pl_182{opacity:1}._item_160pl_165._active_160pl_186{background:var(--accent);color:var(--text-bright)}._sessionDot_160pl_191{background:var(--success,#3fb950);border-radius:50%;flex-shrink:0;width:6px;height:6px}._alertDot_160pl_199{background:var(--warning,#d29922);border-radius:50%;flex-shrink:0;width:6px;height:6px;animation:2s ease-in-out infinite _pulse_160pl_1}@keyframes _pulse_160pl_1{0%,to{opacity:1}50%{opacity:.4}}._itemName_160pl_218{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}._deleteBtn_160pl_182{opacity:0;transition:opacity .15s}._active_160pl_186 ._deleteBtn_160pl_182{opacity:.6}._renameInput_160pl_234{background:var(--bg);border:1px solid var(--link);color:var(--text);border-radius:2px;outline:none;flex:1;padding:1px 4px;font-size:12px}._dirItem_160pl_246{cursor:pointer;align-items:center;gap:6px;padding:4px 12px;font-size:12px;display:flex}._dirItem_160pl_246:hover{background:var(--bg-tertiary)}._dirItem_160pl_246._active_160pl_186{background:var(--accent);color:var(--text-bright)}._empty_160pl_265{color:var(--text-muted);text-align:center;padding:16px 12px;font-size:12px}._emptySmall_160pl_272{color:var(--text-muted);padding:4px 12px 4px 44px;font-size:11px}._footer_160pl_279{border-top:1px solid var(--border);flex-shrink:0;padding:8px}._addMenu_160pl_285{gap:6px;display:flex}._addBtn_160pl_290{color:var(--text-muted);border:1px dashed var(--border);cursor:pointer;text-align:center;background:0 0;border-radius:4px;flex:1;padding:6px 0;font-size:12px}._addBtn_160pl_290:hover{color:var(--text);border-color:var(--text-muted)}._errorMsg_160pl_307{color:var(--danger);word-break:break-all;padding:4px 0;font-size:11px}._dialog_160pl_315{flex-direction:column;gap:6px;display:flex}._dialogInput_160pl_321{background:var(--bg);border:1px solid var(--border);width:100%;color:var(--text);border-radius:3px;outline:none;padding:5px 8px;font-family:inherit;font-size:12px}._dialogInput_160pl_321:focus{border-color:var(--link)}._dialogActions_160pl_337{gap:6px;display:flex}._primaryBtn_160pl_342{background:var(--accent);color:var(--text-bright);cursor:pointer;border:none;border-radius:3px;flex:1;padding:4px 0;font-size:12px}._primaryBtn_160pl_342:hover:not(:disabled){background:var(--accent-hover)}._primaryBtn_160pl_342:disabled{opacity:.5;cursor:default}._secondaryBtn_160pl_362{color:var(--text-muted);border:1px solid var(--border);cursor:pointer;background:0 0;border-radius:3px;padding:4px 10px;font-size:12px}._secondaryBtn_160pl_362:hover:not(:disabled){color:var(--text)}._browsePath_160pl_376{color:var(--text-muted);background:var(--bg);border:1px solid var(--border);text-overflow:ellipsis;white-space:nowrap;border-radius:3px;padding:4px 8px;font-size:11px;overflow:hidden}._browseList_160pl_388{border:1px solid var(--border);background:var(--bg);border-radius:3px;max-height:200px;overflow:auto}._browseItem_160pl_396{cursor:pointer;white-space:nowrap;text-overflow:ellipsis;padding:3px 8px;font-size:12px;overflow:hidden}._browseItem_160pl_396:hover{background:var(--bg-tertiary);color:var(--text-bright)}._handle_q7oed_7{z-index:10;touch-action:none;flex-shrink:0;position:relative}._handleX_q7oed_15{box-sizing:content-box;cursor:col-resize;background:var(--border);background-clip:content-box;align-self:stretch;width:1px;padding:0 2px}._handleY_q7oed_26{box-sizing:content-box;cursor:row-resize;background:var(--border);background-clip:content-box;height:1px;padding:2px 0}._handleEdge_q7oed_37{background:0 0;padding:0;position:absolute}._handleEdge_q7oed_37._handleX_q7oed_15{width:6px;top:0;bottom:0}._handleEdge_q7oed_37._handleY_q7oed_26{height:6px;left:0;right:0}@media (hover:none) and (pointer:coarse){._handleX_q7oed_15:not(._handleEdge_q7oed_37){padding:0 6.5px}._handleY_q7oed_26:not(._handleEdge_q7oed_37){padding:6.5px 0}._handleEdge_q7oed_37._handleX_q7oed_15{width:16px}._handleEdge_q7oed_37._handleY_q7oed_26{height:16px}}._handle_q7oed_7._gripTouch_q7oed_76:after,._handle_q7oed_7._gripHover_q7oed_77:after{content:"";background:var(--text-muted,var(--border));opacity:.5;pointer-events:none;border-radius:999px;display:none;position:absolute}._handleX_q7oed_15._gripTouch_q7oed_76:after,._handleX_q7oed_15._gripHover_q7oed_77:after{width:4px;height:28px;top:50%;left:50%;transform:translate(-50%,-50%)}._handleY_q7oed_26._gripTouch_q7oed_76:after,._handleY_q7oed_26._gripHover_q7oed_77:after{width:28px;height:4px;top:50%;left:50%;transform:translate(-50%,-50%)}._handle_q7oed_7._gripHover_q7oed_77:after{opacity:0;transition:opacity .15s;display:block}._handle_q7oed_7._gripHover_q7oed_77:hover:after{opacity:.5}@media (hover:none) and (pointer:coarse){._handle_q7oed_7._gripTouch_q7oed_76:after,._handle_q7oed_7._gripHover_q7oed_77:after{opacity:.5;display:block}}