@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 +118 -0
- package/bin/omniterm-browser.js +223 -0
- package/bin/omniterm.js +156 -0
- package/bin/xdg-open +6 -0
- package/package.json +44 -0
- package/standalone/client/assets/index-C8vaHx63.css +1 -0
- package/standalone/client/assets/index-CN8KP4yl.js +70 -0
- package/standalone/client/index.html +21 -0
- package/standalone/client/manifest.json +8 -0
- package/standalone/public/manifest.json +8 -0
- package/standalone/server/server.js +90 -0
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
|
+
});
|
package/bin/omniterm.js
ADDED
|
@@ -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}}
|