cli-tunnel 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tamir Dresher
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # cli-tunnel
2
+
3
+ Tunnel any CLI app to your phone — see the exact terminal output in your browser and type back into it.
4
+
5
+ ```bash
6
+ npx cli-tunnel --tunnel copilot --yolo
7
+ npx cli-tunnel --tunnel python -i
8
+ npx cli-tunnel --tunnel htop
9
+ ```
10
+
11
+ ## How It Works
12
+
13
+ 1. Your command runs in a **PTY** (pseudo-terminal) — full TUI with colors, diffs, interactive prompts
14
+ 2. Raw terminal output is streamed over **WebSocket** to **xterm.js** in your browser
15
+ 3. **Microsoft Dev Tunnels** provide an authenticated HTTPS relay — zero servers to deploy
16
+ 4. **Bidirectional**: type on your phone → keystrokes go into the CLI session
17
+ 5. **Private by default**: only your Microsoft/GitHub account can access the tunnel
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install -g cli-tunnel
23
+ ```
24
+
25
+ Or use directly with npx:
26
+
27
+ ```bash
28
+ npx cli-tunnel --tunnel <command> [args...]
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Any flags after the command name are passed directly to the underlying app — cli-tunnel doesn't interpret them.
34
+
35
+ ```bash
36
+ # Start copilot with remote access (--yolo is a copilot flag, not ours)
37
+ cli-tunnel --tunnel copilot --yolo
38
+
39
+ # Pass any flags to the underlying command
40
+ cli-tunnel --tunnel copilot --model claude-sonnet-4 --agent squad
41
+ cli-tunnel --tunnel copilot --allow-all --resume
42
+
43
+ # Name your session (shows in dashboard)
44
+ cli-tunnel --tunnel --name wizard copilot --agent squad
45
+
46
+ # Specific port
47
+ cli-tunnel --tunnel --port 4000 copilot
48
+
49
+ # Works with any CLI app — all their flags pass through
50
+ cli-tunnel --tunnel python -i
51
+ cli-tunnel --tunnel vim myfile.txt
52
+ cli-tunnel --tunnel htop
53
+ cli-tunnel --tunnel ssh user@server
54
+
55
+ # Local only (no tunnel)
56
+ cli-tunnel copilot
57
+ ```
58
+
59
+ **cli-tunnel's own flags** (`--tunnel`, `--port`, `--name`) must come **before** the command. Everything after the command name passes through unchanged.
60
+
61
+ ## What You See on Your Phone
62
+
63
+ - **Full terminal** rendered by xterm.js — exact same output as your local terminal
64
+ - **Key bar** with ↑ ↓ → ← Tab Enter Esc Ctrl+C for mobile navigation
65
+ - **Sessions dashboard** — see all running sessions, tap to connect
66
+ - **Session cleanup** — remove stale tunnels
67
+
68
+ ## Prerequisites
69
+
70
+ - [Node.js](https://nodejs.org/) 22+
71
+ - [Microsoft Dev Tunnels CLI](https://aka.ms/devtunnels/doc) (for `--tunnel` mode)
72
+ ```bash
73
+ winget install Microsoft.devtunnel # Windows
74
+ brew install --cask devtunnel # macOS
75
+ ```
76
+ Then authenticate once: `devtunnel user login`
77
+
78
+ ## Security
79
+
80
+ Tunnels are **private by default** — only the Microsoft/GitHub account that created the tunnel can connect. Auth is enforced at Microsoft's relay layer before traffic reaches your machine.
81
+
82
+ - No inbound ports opened
83
+ - No anonymous access
84
+ - No central server
85
+ - TLS encryption via devtunnel relay
86
+
87
+ ## How It's Built
88
+
89
+ - **[node-pty](https://github.com/microsoft/node-pty)** — spawns the command in a pseudo-terminal
90
+ - **[xterm.js](https://xtermjs.org/)** — terminal emulator in the browser (loaded from CDN)
91
+ - **[ws](https://github.com/websockets/ws)** — WebSocket server for real-time streaming
92
+ - **[Dev Tunnels](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/)** — authenticated HTTPS relay
93
+
94
+ ## Blog Post
95
+
96
+ [Your Copilot CLI on Your Phone — Building Squad Remote Control](https://www.tamirdresher.com/blog/2026/02/26/squad-remote-control)
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cli-tunnel — Tunnel any CLI app to your phone
4
+ *
5
+ * Usage:
6
+ * cli-tunnel <command> [args...] # local only
7
+ * cli-tunnel --tunnel <command> [args...] # with devtunnel remote access
8
+ * cli-tunnel --tunnel --name myapp <command> # named session
9
+ *
10
+ * Examples:
11
+ * cli-tunnel copilot --yolo
12
+ * cli-tunnel --tunnel copilot --yolo
13
+ * cli-tunnel --tunnel --name wizard copilot --agent squad
14
+ * cli-tunnel --tunnel python -i
15
+ * cli-tunnel --tunnel --port 4000 node server.js
16
+ */
17
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cli-tunnel — Tunnel any CLI app to your phone
4
+ *
5
+ * Usage:
6
+ * cli-tunnel <command> [args...] # local only
7
+ * cli-tunnel --tunnel <command> [args...] # with devtunnel remote access
8
+ * cli-tunnel --tunnel --name myapp <command> # named session
9
+ *
10
+ * Examples:
11
+ * cli-tunnel copilot --yolo
12
+ * cli-tunnel --tunnel copilot --yolo
13
+ * cli-tunnel --tunnel --name wizard copilot --agent squad
14
+ * cli-tunnel --tunnel python -i
15
+ * cli-tunnel --tunnel --port 4000 node server.js
16
+ */
17
+ import path from 'node:path';
18
+ import fs from 'node:fs';
19
+ import { execSync, spawn } from 'node:child_process';
20
+ import { fileURLToPath } from 'node:url';
21
+ import http from 'node:http';
22
+ import { WebSocketServer, WebSocket } from 'ws';
23
+ import os from 'node:os';
24
+ const BOLD = '\x1b[1m';
25
+ const RESET = '\x1b[0m';
26
+ const DIM = '\x1b[2m';
27
+ const GREEN = '\x1b[32m';
28
+ const YELLOW = '\x1b[33m';
29
+ // ─── Parse args ─────────────────────────────────────────────
30
+ const args = process.argv.slice(2);
31
+ if (args.includes('--help') || args.includes('-h') || args.length === 0) {
32
+ console.log(`
33
+ ${BOLD}cli-tunnel${RESET} — Tunnel any CLI app to your phone
34
+
35
+ ${BOLD}Usage:${RESET}
36
+ cli-tunnel [options] <command> [args...]
37
+
38
+ ${BOLD}Options:${RESET}
39
+ --tunnel Enable remote access via devtunnel
40
+ --port <n> Bridge port (default: random)
41
+ --name <name> Session name (shown in dashboard)
42
+ --help, -h Show this help
43
+
44
+ ${BOLD}Examples:${RESET}
45
+ cli-tunnel copilot
46
+ cli-tunnel --tunnel copilot --yolo
47
+ cli-tunnel --tunnel copilot --model claude-sonnet-4 --agent squad
48
+ cli-tunnel --tunnel --name wizard copilot --allow-all
49
+ cli-tunnel --tunnel python -i
50
+ cli-tunnel --tunnel htop
51
+
52
+ Any flags after the command name pass through to the underlying
53
+ app. cli-tunnel's own flags (--tunnel, --port, --name) must come
54
+ before the command.
55
+ `);
56
+ process.exit(0);
57
+ }
58
+ const hasTunnel = args.includes('--tunnel');
59
+ const portIdx = args.indexOf('--port');
60
+ const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1], 10) : 0;
61
+ const nameIdx = args.indexOf('--name');
62
+ const sessionName = (nameIdx !== -1 && args[nameIdx + 1]) ? args[nameIdx + 1] : '';
63
+ // Everything that's not our flags is the command
64
+ const ourFlags = new Set(['--tunnel', '--port', '--name']);
65
+ const cmdArgs = [];
66
+ let skip = false;
67
+ for (let i = 0; i < args.length; i++) {
68
+ if (skip) {
69
+ skip = false;
70
+ continue;
71
+ }
72
+ if (ourFlags.has(args[i]) && args[i] !== '--tunnel') {
73
+ skip = true;
74
+ continue;
75
+ }
76
+ if (args[i] === '--tunnel')
77
+ continue;
78
+ cmdArgs.push(args[i]);
79
+ }
80
+ if (cmdArgs.length === 0) {
81
+ console.error('Error: no command specified. Run cli-tunnel --help for usage.');
82
+ process.exit(1);
83
+ }
84
+ const command = cmdArgs[0];
85
+ const commandArgs = cmdArgs.slice(1);
86
+ const cwd = process.cwd();
87
+ // ─── Tunnel helpers ─────────────────────────────────────────
88
+ function sanitizeLabel(l) {
89
+ const clean = l.replace(/[^a-zA-Z0-9_\-=]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 50);
90
+ return clean || 'unknown';
91
+ }
92
+ function getGitInfo() {
93
+ try {
94
+ const remote = execSync('git remote get-url origin', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
95
+ const repo = remote.split('/').pop()?.replace('.git', '') || 'unknown';
96
+ const branch = execSync('git branch --show-current', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() || 'unknown';
97
+ return { repo, branch };
98
+ }
99
+ catch {
100
+ return { repo: path.basename(cwd), branch: 'unknown' };
101
+ }
102
+ }
103
+ // ─── Bridge server ──────────────────────────────────────────
104
+ const acpEventLog = [];
105
+ const connections = new Map();
106
+ const server = http.createServer((req, res) => {
107
+ // Sessions API
108
+ if (req.url === '/api/sessions' && req.method === 'GET') {
109
+ try {
110
+ const output = execSync('devtunnel list --labels cli-tunnel --json', { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
111
+ const data = JSON.parse(output);
112
+ const sessions = (data.tunnels || []).map((t) => {
113
+ const labels = t.labels || [];
114
+ const id = t.tunnelId?.replace(/\.\w+$/, '') || t.tunnelId;
115
+ const cluster = t.tunnelId?.split('.').pop() || 'euw';
116
+ const portLabel = labels.find((l) => l.startsWith('port-'));
117
+ const p = portLabel ? parseInt(portLabel.replace('port-', ''), 10) : 3456;
118
+ return {
119
+ id, tunnelId: t.tunnelId,
120
+ name: labels[1] || 'unnamed',
121
+ repo: labels[2] || 'unknown',
122
+ branch: (labels[3] || 'unknown').replace(/_/g, '/'),
123
+ machine: labels[4] || 'unknown',
124
+ online: (t.hostConnections || 0) > 0,
125
+ port: p,
126
+ url: `https://${id}-${p}.${cluster}.devtunnels.ms`,
127
+ };
128
+ });
129
+ res.writeHead(200, { 'Content-Type': 'application/json' });
130
+ res.end(JSON.stringify({ sessions }));
131
+ }
132
+ catch {
133
+ res.writeHead(200, { 'Content-Type': 'application/json' });
134
+ res.end(JSON.stringify({ sessions: [] }));
135
+ }
136
+ return;
137
+ }
138
+ // Delete session
139
+ if (req.url?.startsWith('/api/sessions/') && req.method === 'DELETE') {
140
+ const tunnelId = req.url.replace('/api/sessions/', '').replace(/\.\w+$/, '');
141
+ try {
142
+ execSync(`devtunnel delete ${tunnelId} --force`, { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'] });
143
+ res.writeHead(200, { 'Content-Type': 'application/json' });
144
+ res.end(JSON.stringify({ deleted: true }));
145
+ }
146
+ catch {
147
+ res.writeHead(200, { 'Content-Type': 'application/json' });
148
+ res.end(JSON.stringify({ deleted: false }));
149
+ }
150
+ return;
151
+ }
152
+ // Static files
153
+ const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../remote-ui');
154
+ let filePath = path.join(uiDir, req.url === '/' ? 'index.html' : req.url || 'index.html');
155
+ if (!filePath.startsWith(uiDir)) {
156
+ res.writeHead(403);
157
+ res.end();
158
+ return;
159
+ }
160
+ if (!fs.existsSync(filePath))
161
+ filePath = path.join(uiDir, 'index.html');
162
+ const ext = path.extname(filePath);
163
+ const mimes = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' };
164
+ res.writeHead(200, { 'Content-Type': mimes[ext] || 'application/octet-stream' });
165
+ fs.createReadStream(filePath).pipe(res);
166
+ });
167
+ const wss = new WebSocketServer({ server });
168
+ wss.on('connection', (ws) => {
169
+ const id = Math.random().toString(36).substring(2);
170
+ connections.set(id, ws);
171
+ // Replay history
172
+ for (const event of acpEventLog) {
173
+ ws.send(JSON.stringify({ type: '_replay', data: event }));
174
+ }
175
+ ws.send(JSON.stringify({ type: '_replay_done' }));
176
+ ws.on('message', (data) => {
177
+ const raw = data.toString();
178
+ try {
179
+ const msg = JSON.parse(raw);
180
+ if (msg.type === 'pty_input' && ptyProcess) {
181
+ ptyProcess.write(msg.data);
182
+ }
183
+ if (msg.type === 'pty_resize' && ptyProcess) {
184
+ ptyProcess.resize(msg.cols, msg.rows);
185
+ }
186
+ }
187
+ catch {
188
+ if (ptyProcess)
189
+ ptyProcess.write(raw + '\r');
190
+ }
191
+ });
192
+ ws.on('close', () => connections.delete(id));
193
+ });
194
+ function broadcast(data) {
195
+ const msg = JSON.stringify({ type: 'pty', data });
196
+ acpEventLog.push(msg);
197
+ if (acpEventLog.length > 2000)
198
+ acpEventLog.splice(0, acpEventLog.length - 2000);
199
+ for (const [, ws] of connections) {
200
+ if (ws.readyState === WebSocket.OPEN)
201
+ ws.send(msg);
202
+ }
203
+ }
204
+ // ─── Start bridge ───────────────────────────────────────────
205
+ let ptyProcess = null;
206
+ async function main() {
207
+ const actualPort = await new Promise((resolve, reject) => {
208
+ server.listen(port, () => {
209
+ const addr = server.address();
210
+ resolve(typeof addr === 'object' ? addr.port : port);
211
+ });
212
+ server.on('error', reject);
213
+ });
214
+ const { repo, branch } = getGitInfo();
215
+ const machine = os.hostname();
216
+ const displayName = sessionName || command;
217
+ console.log(`\n${BOLD}cli-tunnel${RESET} ${DIM}v1.0.0${RESET}\n`);
218
+ console.log(` ${DIM}Command:${RESET} ${command} ${commandArgs.join(' ')}`);
219
+ console.log(` ${DIM}Name:${RESET} ${displayName}`);
220
+ console.log(` ${DIM}Port:${RESET} ${actualPort}`);
221
+ // Tunnel
222
+ if (hasTunnel) {
223
+ try {
224
+ execSync('devtunnel --version', { stdio: 'pipe' });
225
+ const labels = ['cli-tunnel', sanitizeLabel(sessionName || command), sanitizeLabel(repo), sanitizeLabel(branch), sanitizeLabel(machine), `port-${actualPort}`]
226
+ .map(l => `--labels ${l}`).join(' ');
227
+ const createOut = execSync(`devtunnel create ${labels} --expiration 1d --json`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
228
+ const tunnelId = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[0];
229
+ const cluster = JSON.parse(createOut).tunnel?.tunnelId?.split('.')[1] || 'euw';
230
+ execSync(`devtunnel port create ${tunnelId} -p ${actualPort} --protocol http`, { stdio: 'pipe' });
231
+ const hostProc = spawn('devtunnel', ['host', tunnelId], { stdio: 'pipe', detached: false });
232
+ const url = await new Promise((resolve, reject) => {
233
+ const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 15000);
234
+ let out = '';
235
+ hostProc.stdout?.on('data', (d) => {
236
+ out += d.toString();
237
+ const match = out.match(/https:\/\/[^\s]+/);
238
+ if (match) {
239
+ clearTimeout(timeout);
240
+ resolve(match[0]);
241
+ }
242
+ });
243
+ hostProc.on('error', (e) => { clearTimeout(timeout); reject(e); });
244
+ });
245
+ console.log(` ${GREEN}✓${RESET} Tunnel: ${BOLD}${url}${RESET}\n`);
246
+ try {
247
+ // @ts-ignore
248
+ const qr = (await import('qrcode-terminal'));
249
+ qr.default.generate(url, { small: true }, (code) => console.log(code));
250
+ }
251
+ catch { }
252
+ process.on('SIGINT', () => { hostProc.kill(); try {
253
+ execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
254
+ }
255
+ catch { } });
256
+ process.on('exit', () => { hostProc.kill(); try {
257
+ execSync(`devtunnel delete ${tunnelId} --force`, { stdio: 'pipe' });
258
+ }
259
+ catch { } });
260
+ }
261
+ catch (err) {
262
+ console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${err.message}\n`);
263
+ }
264
+ }
265
+ console.log(` ${DIM}Starting ${command}...${RESET}\n`);
266
+ // Spawn PTY
267
+ const nodePty = await import('node-pty');
268
+ const cols = process.stdout.columns || 120;
269
+ const rows = process.stdout.rows || 30;
270
+ // Resolve command path for node-pty on Windows
271
+ let resolvedCmd = command;
272
+ if (process.platform === 'win32') {
273
+ try {
274
+ const wherePaths = execSync(`where ${command}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim().split('\n');
275
+ // Prefer .exe or .cmd over .ps1 for node-pty compatibility
276
+ const exePath = wherePaths.find(p => p.trim().endsWith('.exe')) || wherePaths.find(p => p.trim().endsWith('.cmd'));
277
+ if (exePath) {
278
+ resolvedCmd = exePath.trim();
279
+ }
280
+ else {
281
+ // For .ps1 scripts, wrap with powershell
282
+ resolvedCmd = 'powershell';
283
+ commandArgs.unshift('-File', wherePaths[0].trim());
284
+ }
285
+ }
286
+ catch { /* use as-is */ }
287
+ }
288
+ ptyProcess = nodePty.spawn(resolvedCmd, commandArgs, {
289
+ name: 'xterm-256color',
290
+ cols, rows, cwd,
291
+ env: process.env,
292
+ });
293
+ ptyProcess.onData((data) => {
294
+ process.stdout.write(data);
295
+ broadcast(data);
296
+ });
297
+ ptyProcess.onExit(({ exitCode }) => {
298
+ console.log(`\n${DIM}Process exited (code ${exitCode}).${RESET}`);
299
+ server.close();
300
+ process.exit(exitCode);
301
+ });
302
+ if (process.stdin.isTTY)
303
+ process.stdin.setRawMode(true);
304
+ process.stdin.resume();
305
+ process.stdin.on('data', (data) => ptyProcess.write(data.toString()));
306
+ process.stdout.on('resize', () => ptyProcess.resize(process.stdout.columns || 120, process.stdout.rows || 30));
307
+ }
308
+ main().catch((err) => { console.error(err); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "cli-tunnel",
3
+ "version": "1.0.0",
4
+ "description": "Tunnel any CLI app to your phone - PTY + devtunnel + xterm.js",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "cli-tunnel": "./dist/index.js"
9
+ },
10
+ "files": ["dist", "remote-ui"],
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "vitest run"
14
+ },
15
+ "keywords": ["cli", "tunnel", "remote", "pty", "xterm", "devtunnel", "copilot", "terminal"],
16
+ "author": "Tamir Dresher",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/tamirdresher/cli-tunnel.git"
21
+ },
22
+ "engines": {
23
+ "node": ">=22.0.0"
24
+ },
25
+ "dependencies": {
26
+ "node-pty": "^1.1.0",
27
+ "qrcode-terminal": "^0.12.0",
28
+ "ws": "^8.19.0"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^25.3.2",
32
+ "@types/ws": "^8.18.1",
33
+ "typescript": "^5.9.3",
34
+ "vitest": "^4.0.18"
35
+ }
36
+ }
@@ -0,0 +1,551 @@
1
+ /**
2
+ * cli-tunnel — Terminal-Style PWA (ACP Protocol)
3
+ * Raw terminal rendering matching Copilot CLI output
4
+ */
5
+ (function () {
6
+ 'use strict';
7
+
8
+ let ws = null;
9
+ let connected = false;
10
+ let sessionId = null;
11
+ let requestId = 0;
12
+ let pendingRequests = {};
13
+ let acpReady = false;
14
+ let streamingEl = null;
15
+ let replaying = false;
16
+ let toolCalls = {};
17
+
18
+ const $ = (sel) => document.querySelector(sel);
19
+ const terminal = $('#terminal');
20
+ const inputEl = $('#input');
21
+ const formEl = $('#input-form');
22
+ const statusEl = $('#status-indicator');
23
+ const statusText = $('#status-text');
24
+ const permOverlay = $('#permission-overlay');
25
+ const dashboard = $('#dashboard');
26
+ const termContainer = $('#terminal-container');
27
+ let currentView = 'terminal'; // 'dashboard' or 'terminal'
28
+
29
+ // ─── xterm.js Terminal ───────────────────────────────────
30
+ let xterm = null;
31
+ let fitAddon = null;
32
+
33
+ function initXterm() {
34
+ if (xterm) return;
35
+ xterm = new Terminal({
36
+ theme: {
37
+ background: '#0d1117',
38
+ foreground: '#c9d1d9',
39
+ cursor: '#3fb950',
40
+ selectionBackground: '#264f78',
41
+ black: '#0d1117',
42
+ red: '#f85149',
43
+ green: '#3fb950',
44
+ yellow: '#d29922',
45
+ blue: '#58a6ff',
46
+ magenta: '#bc8cff',
47
+ cyan: '#39c5cf',
48
+ white: '#c9d1d9',
49
+ brightBlack: '#6e7681',
50
+ brightRed: '#f85149',
51
+ brightGreen: '#3fb950',
52
+ brightYellow: '#d29922',
53
+ brightBlue: '#58a6ff',
54
+ brightMagenta: '#bc8cff',
55
+ brightCyan: '#39c5cf',
56
+ brightWhite: '#f0f6fc',
57
+ },
58
+ fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace",
59
+ fontSize: 13,
60
+ scrollback: 5000,
61
+ cursorBlink: true,
62
+ });
63
+
64
+ fitAddon = new FitAddon.FitAddon();
65
+ xterm.loadAddon(fitAddon);
66
+ xterm.open(termContainer);
67
+ fitAddon.fit();
68
+
69
+ // Send terminal size to PTY so copilot renders correctly
70
+ function sendResize() {
71
+ if (ws && ws.readyState === WebSocket.OPEN && xterm) {
72
+ ws.send(JSON.stringify({ type: 'pty_resize', cols: xterm.cols, rows: xterm.rows }));
73
+ }
74
+ }
75
+
76
+ // Handle resize
77
+ window.addEventListener('resize', () => {
78
+ if (fitAddon) { fitAddon.fit(); sendResize(); }
79
+ });
80
+
81
+ // Send initial size
82
+ setTimeout(sendResize, 500);
83
+
84
+ // Keyboard input → send to bridge → PTY
85
+ xterm.onData((data) => {
86
+ if (ws && ws.readyState === WebSocket.OPEN) {
87
+ ws.send(JSON.stringify({ type: 'pty_input', data }));
88
+ }
89
+ });
90
+ }
91
+
92
+ // ─── Dashboard ───────────────────────────────────────────
93
+ let showOffline = false;
94
+
95
+ async function loadSessions() {
96
+ try {
97
+ const resp = await fetch('/api/sessions');
98
+ const data = await resp.json();
99
+ renderDashboard(data.sessions || []);
100
+ } catch (err) {
101
+ dashboard.innerHTML = '<div style="padding:12px;color:var(--red)">Failed to load sessions: ' + err.message + '</div>';
102
+ }
103
+ }
104
+
105
+ function renderDashboard(sessions) {
106
+ const filtered = showOffline ? sessions : sessions.filter(s => s.online);
107
+ const offlineCount = sessions.filter(s => !s.online).length;
108
+ const onlineCount = sessions.filter(s => s.online).length;
109
+
110
+ let html = `<div style="padding:8px 4px;display:flex;align-items:center;gap:8px">
111
+ <span style="color:var(--text-dim);font-size:12px">${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''}</span>
112
+ <span style="flex:1"></span>
113
+ <button onclick="toggleOffline()" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">${showOffline ? 'Hide offline' : 'Show offline'}</button>
114
+ ${offlineCount > 0 ? '<button onclick="cleanOffline()" style="background:none;border:1px solid var(--red);color:var(--red);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">Clean offline</button>' : ''}
115
+ <button onclick="loadSessions()" style="background:none;border:1px solid var(--border);color:var(--text-dim);font-family:var(--font);font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer">↻</button>
116
+ </div>`;
117
+
118
+ if (filtered.length === 0) {
119
+ html += '<div style="padding:20px 12px;color:var(--text-dim);text-align:center">' +
120
+ (sessions.length === 0 ? 'No Squad RC sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') +
121
+ '</div>';
122
+ } else {
123
+ html += filtered.map(s => `
124
+ <div class="session-card" ${s.online ? 'onclick="openSession(\'' + escapeHtml(s.url) + '\')"' : ''}>
125
+ <span class="status-dot ${s.online ? 'online' : 'offline'}"></span>
126
+ <div class="info">
127
+ <div class="repo">📦 ${escapeHtml(s.repo)}</div>
128
+ <div class="branch">🌿 ${escapeHtml(s.branch)}</div>
129
+ <div class="machine">💻 ${escapeHtml(s.machine)}</div>
130
+ </div>
131
+ ${s.online ? '<span class="arrow">→</span>' :
132
+ '<button onclick="event.stopPropagation();deleteSession(\'' + escapeHtml(s.id) + '\')" style="background:none;border:none;color:var(--red);cursor:pointer;font-size:14px" title="Remove">✕</button>'}
133
+ </div>
134
+ `).join('');
135
+ }
136
+ dashboard.innerHTML = html;
137
+ }
138
+
139
+ window.openSession = (url) => {
140
+ window.location.href = url;
141
+ };
142
+
143
+ window.toggleOffline = () => {
144
+ showOffline = !showOffline;
145
+ loadSessions();
146
+ };
147
+
148
+ window.cleanOffline = async () => {
149
+ const resp = await fetch('/api/sessions');
150
+ const data = await resp.json();
151
+ const offline = (data.sessions || []).filter(s => !s.online);
152
+ for (const s of offline) {
153
+ await fetch('/api/sessions/' + s.id, { method: 'DELETE' });
154
+ }
155
+ loadSessions();
156
+ };
157
+
158
+ window.deleteSession = async (id) => {
159
+ await fetch('/api/sessions/' + id, { method: 'DELETE' });
160
+ loadSessions();
161
+ };
162
+
163
+ window.toggleView = () => {
164
+ if (currentView === 'terminal') {
165
+ currentView = 'dashboard';
166
+ terminal.classList.add('hidden');
167
+ termContainer.classList.add('hidden');
168
+ $('#input-area').classList.add('hidden');
169
+ dashboard.classList.remove('hidden');
170
+ $('#btn-sessions').textContent = 'Terminal';
171
+ loadSessions();
172
+ } else {
173
+ currentView = 'terminal';
174
+ dashboard.classList.add('hidden');
175
+ $('#input-area').classList.remove('hidden');
176
+ if (ptyMode) {
177
+ termContainer.classList.remove('hidden');
178
+ $('#input-form').classList.add('hidden');
179
+ if (fitAddon) fitAddon.fit();
180
+ if (xterm) xterm.focus();
181
+ } else {
182
+ terminal.classList.remove('hidden');
183
+ }
184
+ $('#btn-sessions').textContent = 'Sessions';
185
+ }
186
+ };
187
+
188
+ // ─── Terminal Output ─────────────────────────────────────
189
+ function write(html, cls) {
190
+ const div = document.createElement('div');
191
+ if (cls) div.className = cls;
192
+ div.innerHTML = html;
193
+ terminal.appendChild(div);
194
+ if (!replaying) scrollToBottom();
195
+ }
196
+
197
+ function writeSys(text) { write(escapeHtml(text), 'sys'); }
198
+
199
+ function writeUserInput(text) {
200
+ write(escapeHtml(text), 'user-input');
201
+ }
202
+
203
+ function startStreaming() {
204
+ streamingEl = document.createElement('div');
205
+ streamingEl.className = 'agent-text';
206
+ streamingEl.innerHTML = '<span class="cursor"></span>';
207
+ terminal.appendChild(streamingEl);
208
+ }
209
+
210
+ function appendStreaming(text) {
211
+ if (!streamingEl) startStreaming();
212
+ // Remove cursor, append text, re-add cursor
213
+ const cursor = streamingEl.querySelector('.cursor');
214
+ if (cursor) cursor.remove();
215
+ streamingEl.innerHTML += escapeHtml(text);
216
+ const c = document.createElement('span');
217
+ c.className = 'cursor';
218
+ streamingEl.appendChild(c);
219
+ if (!replaying) scrollToBottom();
220
+ }
221
+
222
+ function endStreaming() {
223
+ if (streamingEl) {
224
+ const cursor = streamingEl.querySelector('.cursor');
225
+ if (cursor) cursor.remove();
226
+ // Render markdown-ish formatting
227
+ streamingEl.innerHTML = formatText(streamingEl.textContent || '');
228
+ streamingEl = null;
229
+ }
230
+ }
231
+
232
+ // ─── Tool Call Rendering ─────────────────────────────────
233
+ function renderToolCall(update) {
234
+ const id = update.id || update.toolCallId || ('tc-' + Date.now());
235
+ const name = update.name || 'tool';
236
+ const icons = { read: '📖', edit: '✏️', write: '✏️', shell: '▶️', search: '🔍', think: '💭', fetch: '🌐' };
237
+ const guessKind = name.includes('read') ? 'read' : name.includes('edit') || name.includes('write') ? 'edit' :
238
+ name.includes('shell') || name.includes('exec') || name.includes('run') ? 'shell' :
239
+ name.includes('search') || name.includes('grep') || name.includes('glob') ? 'search' :
240
+ name.includes('think') || name.includes('reason') ? 'think' : 'other';
241
+ const icon = icons[guessKind] || '⚙️';
242
+
243
+ const el = document.createElement('div');
244
+ el.className = 'tool-call';
245
+ el.id = 'tool-' + id;
246
+ el.dataset.toolId = id;
247
+
248
+ const inputStr = update.input ? (typeof update.input === 'string' ? update.input : JSON.stringify(update.input)) : '';
249
+ const shortInput = inputStr.length > 80 ? inputStr.substring(0, 80) + '...' : inputStr;
250
+
251
+ el.innerHTML = `<span class="tool-icon">${icon}</span><span class="tool-name">${escapeHtml(name)}</span> ${escapeHtml(shortInput)}<span class="tool-status in_progress">⟳</span><div class="tool-body"></div>`;
252
+ el.addEventListener('click', () => el.classList.toggle('expanded'));
253
+
254
+ terminal.appendChild(el);
255
+ toolCalls[id] = el;
256
+ if (!replaying) scrollToBottom();
257
+ }
258
+
259
+ function updateToolCall(update) {
260
+ const id = update.id || update.toolCallId;
261
+ const el = toolCalls[id];
262
+ if (!el) return;
263
+
264
+ if (update.status) {
265
+ el.classList.remove('completed', 'failed');
266
+ if (update.status === 'completed') el.classList.add('completed');
267
+ if (update.status === 'failed' || update.status === 'errored') el.classList.add('failed');
268
+
269
+ const badge = el.querySelector('.tool-status');
270
+ if (badge) {
271
+ badge.className = 'tool-status ' + update.status;
272
+ badge.textContent = update.status === 'completed' ? '✓' : update.status === 'failed' || update.status === 'errored' ? '✗' : '⟳';
273
+ }
274
+ }
275
+
276
+ if (update.content) {
277
+ const body = el.querySelector('.tool-body');
278
+ if (body) {
279
+ for (const item of (Array.isArray(update.content) ? update.content : [update.content])) {
280
+ if (item.type === 'diff' && item.diff) {
281
+ let diffHtml = `<div class="diff"><div class="diff-header">${escapeHtml(item.path || '')}</div>`;
282
+ if (item.diff.before) diffHtml += `<div class="diff-del">${escapeHtml(item.diff.before)}</div>`;
283
+ if (item.diff.after) diffHtml += `<div class="diff-add">${escapeHtml(item.diff.after)}</div>`;
284
+ diffHtml += '</div>';
285
+ body.innerHTML += diffHtml;
286
+ } else if (item.type === 'text' && item.text) {
287
+ body.innerHTML += `<div class="code-block">${escapeHtml(item.text)}</div>`;
288
+ } else if (typeof item === 'string') {
289
+ body.innerHTML += `<div class="code-block">${escapeHtml(item)}</div>`;
290
+ }
291
+ }
292
+ el.classList.add('expanded');
293
+ }
294
+ }
295
+ }
296
+
297
+ // ─── ACP JSON-RPC ────────────────────────────────────────
298
+ function sendRequest(method, params, timeoutMs) {
299
+ return new Promise((resolve, reject) => {
300
+ const id = ++requestId;
301
+ pendingRequests[id] = { resolve, reject };
302
+ const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params });
303
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg);
304
+ const timeout = timeoutMs !== undefined ? timeoutMs : (method === 'initialize' ? 60000 : 120000);
305
+ if (timeout > 0) {
306
+ setTimeout(() => {
307
+ if (pendingRequests[id]) { delete pendingRequests[id]; reject(new Error(`${method} timed out`)); }
308
+ }, timeout);
309
+ }
310
+ });
311
+ }
312
+
313
+ // ─── ACP Initialize ─────────────────────────────────────
314
+ async function initializeACP(attempt) {
315
+ attempt = attempt || 1;
316
+ setStatus('connecting', attempt === 1 ? 'Initializing...' : `Retry ${attempt}/5...`);
317
+ if (attempt === 1) writeSys('Waiting for Copilot to load (~15-20s)...');
318
+
319
+ try {
320
+ const result = await sendRequest('initialize', {
321
+ protocolVersion: 1, clientCapabilities: {},
322
+ clientInfo: { name: 'squad-rc', title: 'Squad RC', version: '1.0.0' },
323
+ });
324
+ writeSys('Connected to Copilot ' + (result.agentInfo?.version || ''));
325
+ const sessionResult = await sendRequest('session/new', { cwd: '.', mcpServers: [] });
326
+ sessionId = sessionResult.sessionId;
327
+ acpReady = true;
328
+ setStatus('online', 'Ready');
329
+ writeSys('Session ready. Type a message below.');
330
+ } catch (err) {
331
+ if (attempt < 5) {
332
+ writeSys('Not ready, retrying in 5s... (' + attempt + '/5)');
333
+ setTimeout(() => initializeACP(attempt + 1), 5000);
334
+ } else {
335
+ setStatus('offline', 'Failed');
336
+ writeSys('Failed to connect: ' + err.message);
337
+ }
338
+ }
339
+ }
340
+
341
+ // ─── WebSocket ───────────────────────────────────────────
342
+ function connect() {
343
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
344
+ ws = new WebSocket(`${proto}//${location.host}`);
345
+ setStatus('connecting', 'Connecting...');
346
+
347
+ ws.onopen = () => {
348
+ connected = true;
349
+ setTimeout(() => initializeACP(1), 1000);
350
+ };
351
+ ws.onclose = () => {
352
+ connected = false; acpReady = false; sessionId = null;
353
+ setStatus('offline', 'Disconnected');
354
+ setTimeout(connect, 3000);
355
+ };
356
+ ws.onerror = () => setStatus('offline', 'Error');
357
+ ws.onmessage = (e) => {
358
+ try {
359
+ const msg = JSON.parse(e.data);
360
+ handleMessage(msg);
361
+ } catch {}
362
+ };
363
+ }
364
+
365
+ // ─── Message Handler ─────────────────────────────────────
366
+ function handleMessage(msg) {
367
+ // Replay events from bridge recording
368
+ if (msg.type === '_replay') {
369
+ replaying = true;
370
+ try { handleMessage(JSON.parse(msg.data)); } catch {}
371
+ return;
372
+ }
373
+ if (msg.type === '_replay_done') {
374
+ replaying = false;
375
+ scrollToBottom();
376
+ return;
377
+ }
378
+
379
+ // PTY data — raw terminal output → xterm.js
380
+ if (msg.type === 'pty') {
381
+ if (!ptyMode) {
382
+ ptyMode = true;
383
+ setStatus('online', 'PTY Mirror');
384
+ terminal.classList.add('hidden');
385
+ // Hide text input form but keep key bar visible
386
+ $('#input-form').classList.add('hidden');
387
+ termContainer.classList.remove('hidden');
388
+ initXterm();
389
+ }
390
+ xterm.write(msg.data);
391
+ return;
392
+ }
393
+
394
+ // JSON-RPC response (ACP mode fallback)
395
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
396
+ const p = pendingRequests[msg.id];
397
+ if (p) {
398
+ delete pendingRequests[msg.id];
399
+ msg.error ? p.reject(new Error(msg.error.message || 'Error')) : p.resolve(msg.result);
400
+ }
401
+ if (msg.result?.stopReason) endStreaming();
402
+ return;
403
+ }
404
+
405
+ // session/update notification (ACP mode fallback)
406
+ if (msg.method === 'session/update' && msg.params) {
407
+ const u = msg.params.update || msg.params;
408
+ if (u.sessionUpdate === 'agent_message_chunk' && u.content?.text) {
409
+ appendStreaming(u.content.text);
410
+ }
411
+ if (u.sessionUpdate === 'tool_call') renderToolCall(u);
412
+ if (u.sessionUpdate === 'tool_call_update') updateToolCall(u);
413
+ return;
414
+ }
415
+
416
+ // Permission request (ACP mode)
417
+ if (msg.method === 'session/request_permission') {
418
+ showPermission(msg);
419
+ return;
420
+ }
421
+ }
422
+
423
+ // ─── PTY Terminal Rendering ──────────────────────────────
424
+ function appendTerminalData(data) {
425
+ // Strip some ANSI sequences that don't render well in HTML
426
+ // but keep colors and basic formatting
427
+ const html = ansiToHtml(data);
428
+ terminal.innerHTML += html;
429
+ if (!replaying) scrollToBottom();
430
+ }
431
+
432
+ function ansiToHtml(text) {
433
+ // Convert ANSI escape codes to HTML spans
434
+ let html = escapeHtml(text);
435
+
436
+ // Color codes → spans
437
+ const colorMap = {
438
+ '30': '#6e7681', '31': '#f85149', '32': '#3fb950', '33': '#d29922',
439
+ '34': '#58a6ff', '35': '#bc8cff', '36': '#39c5cf', '37': '#c9d1d9',
440
+ '90': '#6e7681', '91': '#f85149', '92': '#3fb950', '93': '#d29922',
441
+ '94': '#58a6ff', '95': '#bc8cff', '96': '#39c5cf', '97': '#f0f6fc',
442
+ };
443
+
444
+ // Replace \x1b[Xm patterns
445
+ html = html.replace(/\x1b\[(\d+)m/g, (_, code) => {
446
+ if (code === '0') return '</span>';
447
+ if (code === '1') return '<span style="font-weight:bold">';
448
+ if (code === '2') return '<span style="opacity:0.6">';
449
+ if (code === '4') return '<span style="text-decoration:underline">';
450
+ if (colorMap[code]) return `<span style="color:${colorMap[code]}">`;
451
+ return '';
452
+ });
453
+
454
+ // Clean up escape sequences we don't handle
455
+ html = html.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
456
+ // Clean \r
457
+ html = html.replace(/\r/g, '');
458
+
459
+ return html;
460
+ }
461
+
462
+ // ─── Permission Dialog ───────────────────────────────────
463
+ function showPermission(msg) {
464
+ const p = msg.params || {};
465
+ // Extract readable info from the permission request
466
+ const toolCall = p.toolCall || {};
467
+ const title = toolCall.title || p.tool || 'Tool action';
468
+ const kind = toolCall.kind || 'unknown';
469
+ const kindIcons = { read: '📖', edit: '✏️', execute: '▶️', delete: '🗑️' };
470
+ const icon = kindIcons[kind] || '🔧';
471
+ // For shell commands, show just the first line
472
+ const command = toolCall.rawInput?.command || toolCall.rawInput?.commands?.[0] || '';
473
+ const shortCmd = command.split('\n')[0].substring(0, 100) + (command.length > 100 ? '...' : '');
474
+
475
+ permOverlay.classList.remove('hidden');
476
+ permOverlay.innerHTML = `<div class="perm-dialog">
477
+ <h3>${icon} ${escapeHtml(title)}</h3>
478
+ <p>${escapeHtml(shortCmd || JSON.stringify(p).substring(0, 200))}</p>
479
+ <div class="perm-actions">
480
+ <button class="btn-deny" onclick="handlePerm(${msg.id}, false)">Deny</button>
481
+ <button class="btn-approve" onclick="handlePerm(${msg.id}, true)">Approve</button>
482
+ </div>
483
+ </div>`;
484
+ }
485
+ window.handlePerm = (id, approved) => {
486
+ if (ws?.readyState === WebSocket.OPEN) {
487
+ ws.send(JSON.stringify({ jsonrpc: '2.0', id, result: { outcome: approved ? 'approved' : 'denied' } }));
488
+ }
489
+ permOverlay.classList.add('hidden');
490
+ };
491
+
492
+ // ─── Mobile Key Bar ───────────────────────────────────────
493
+ window.sendKey = (key) => {
494
+ if (ws && ws.readyState === WebSocket.OPEN) {
495
+ ws.send(JSON.stringify({ type: 'pty_input', data: key }));
496
+ }
497
+ if (xterm) xterm.focus();
498
+ };
499
+
500
+ // ─── Send Prompt ─────────────────────────────────────────
501
+ let ptyMode = false;
502
+
503
+ formEl.addEventListener('submit', async (e) => {
504
+ e.preventDefault();
505
+ const text = inputEl.value.trim();
506
+ if (!text) return;
507
+ inputEl.value = '';
508
+
509
+ if (ptyMode) {
510
+ // xterm.js handles input directly — focus it
511
+ if (xterm) xterm.focus();
512
+ return;
513
+ }
514
+
515
+ // ACP mode
516
+ if (!acpReady || !sessionId) return;
517
+ writeUserInput(text);
518
+ try {
519
+ await sendRequest('session/prompt', {
520
+ sessionId, prompt: [{ type: 'text', text }],
521
+ }, 0);
522
+ } catch (err) {
523
+ endStreaming();
524
+ writeSys('Error: ' + err.message);
525
+ }
526
+ });
527
+
528
+ // ─── Helpers ─────────────────────────────────────────────
529
+ function setStatus(state, text) {
530
+ statusEl.className = state;
531
+ statusText.textContent = text;
532
+ }
533
+ function scrollToBottom() {
534
+ requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; });
535
+ }
536
+ function escapeHtml(s) {
537
+ const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML;
538
+ }
539
+ function formatText(text) {
540
+ return escapeHtml(text)
541
+ .replace(/```(\w*)\n([\s\S]*?)```/g, '<div class="code-block">$2</div>')
542
+ .replace(/`([^`]+)`/g, '<code style="background:var(--bg-tool);padding:1px 4px;border-radius:3px">$1</code>')
543
+ .replace(/\*\*([^*]+)\*\*/g, '<strong style="color:var(--text-bright)">$1</strong>')
544
+ .replace(/\n/g, '<br>');
545
+ }
546
+
547
+ // ─── Start ───────────────────────────────────────────────
548
+ writeSys('cli-tunnel');
549
+ connect();
550
+ })();
551
+
@@ -0,0 +1,58 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <meta name="theme-color" content="#0d1117">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <title>cli-tunnel</title>
10
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
11
+ <link rel="stylesheet" href="/styles.css">
12
+ </head>
13
+ <body>
14
+ <div id="app">
15
+ <header id="header">
16
+ <span id="status-indicator">●</span>
17
+ <span id="status-text">Connecting...</span>
18
+ <span style="flex:1"></span>
19
+ <button id="btn-sessions" onclick="toggleView()" style="background:none;border:none;color:var(--text-dim);font-family:var(--font);font-size:12px;cursor:pointer">Sessions</button>
20
+ </header>
21
+
22
+ <!-- Dashboard view -->
23
+ <div id="dashboard" class="hidden">
24
+ <div style="padding:12px;color:var(--text-dim);font-size:12px">Loading sessions...</div>
25
+ </div>
26
+
27
+ <!-- Terminal view (xterm.js) -->
28
+ <div id="terminal-container"></div>
29
+
30
+ <!-- Legacy terminal (ACP mode fallback) -->
31
+ <main id="terminal" class="hidden"></main>
32
+
33
+ <footer id="input-area">
34
+ <div id="key-bar">
35
+ <button onclick="sendKey('\x1b[A')">↑</button>
36
+ <button onclick="sendKey('\x1b[B')">↓</button>
37
+ <button onclick="sendKey('\x1b[C')">→</button>
38
+ <button onclick="sendKey('\x1b[D')">←</button>
39
+ <button onclick="sendKey('\t')">Tab</button>
40
+ <button onclick="sendKey('\r')">Enter</button>
41
+ <button onclick="sendKey('\x1b')">Esc</button>
42
+ <button onclick="sendKey('\x03')">Ctrl+C</button>
43
+ <button onclick="sendKey(' ')">Space</button>
44
+ <button onclick="sendKey('\x7f')">⌫</button>
45
+ </div>
46
+ <form id="input-form">
47
+ <span class="prompt">&gt;</span>
48
+ <input type="text" id="input" placeholder="Send a message..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
49
+ </form>
50
+ </footer>
51
+ </div>
52
+ <div id="permission-overlay" class="hidden"></div>
53
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
54
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
55
+ <script src="/app.js"></script>
56
+ </body>
57
+ </html>
58
+
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "Squad RC",
3
+ "short_name": "Squad",
4
+ "description": "Remote control for your Squad AI team",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#1a1a2e",
8
+ "theme_color": "#1a1a2e",
9
+ "icons": []
10
+ }
@@ -0,0 +1,249 @@
1
+ :root {
2
+ --bg: #0d1117;
3
+ --bg-tool: #161b22;
4
+ --text: #c9d1d9;
5
+ --text-dim: #6e7681;
6
+ --text-bright: #f0f6fc;
7
+ --green: #3fb950;
8
+ --red: #f85149;
9
+ --yellow: #d29922;
10
+ --blue: #58a6ff;
11
+ --purple: #bc8cff;
12
+ --cyan: #39c5cf;
13
+ --border: #30363d;
14
+ --font: 'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', 'Consolas', monospace;
15
+ }
16
+
17
+ * { margin: 0; padding: 0; box-sizing: border-box; }
18
+
19
+ body {
20
+ font-family: var(--font);
21
+ font-size: 13px;
22
+ background: var(--bg);
23
+ color: var(--text);
24
+ height: 100dvh;
25
+ overflow: hidden;
26
+ -webkit-font-smoothing: antialiased;
27
+ }
28
+
29
+ #app {
30
+ display: flex;
31
+ flex-direction: column;
32
+ height: 100dvh;
33
+ }
34
+
35
+ /* Header — minimal status bar */
36
+ header {
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 8px;
40
+ padding: 6px 12px;
41
+ background: var(--bg-tool);
42
+ border-bottom: 1px solid var(--border);
43
+ flex-shrink: 0;
44
+ font-size: 12px;
45
+ }
46
+ #status-indicator { font-size: 10px; }
47
+ #status-indicator.online { color: var(--green); }
48
+ #status-indicator.offline { color: var(--red); }
49
+ #status-indicator.connecting { color: var(--yellow); }
50
+ #status-text { color: var(--text-dim); }
51
+
52
+ /* Terminal area (legacy) */
53
+ #terminal {
54
+ flex: 1;
55
+ overflow-y: auto;
56
+ padding: 8px 12px;
57
+ white-space: pre-wrap;
58
+ word-wrap: break-word;
59
+ line-height: 1.5;
60
+ scroll-behavior: smooth;
61
+ }
62
+
63
+ /* xterm.js container */
64
+ #terminal-container {
65
+ flex: 1;
66
+ overflow: hidden;
67
+ }
68
+ #terminal-container .xterm {
69
+ height: 100%;
70
+ padding: 4px;
71
+ }
72
+
73
+ /* System messages */
74
+ .sys { color: var(--text-dim); font-style: italic; }
75
+
76
+ /* User input echo */
77
+ .user-input { color: var(--blue); }
78
+ .user-input::before { content: '❯ '; color: var(--green); }
79
+
80
+ /* Agent text */
81
+ .agent-text { color: var(--text); }
82
+
83
+ /* Streaming cursor */
84
+ .cursor {
85
+ display: inline-block;
86
+ width: 7px; height: 14px;
87
+ background: var(--text);
88
+ animation: blink 0.7s infinite;
89
+ vertical-align: text-bottom;
90
+ margin-left: 1px;
91
+ }
92
+ @keyframes blink { 50% { opacity: 0; } }
93
+
94
+ /* Tool calls */
95
+ .tool-call {
96
+ margin: 4px 0;
97
+ border-left: 2px solid var(--blue);
98
+ padding: 2px 0 2px 8px;
99
+ color: var(--text-dim);
100
+ font-size: 12px;
101
+ }
102
+ .tool-call.completed { border-left-color: var(--green); }
103
+ .tool-call.failed { border-left-color: var(--red); }
104
+ .tool-call .tool-icon { margin-right: 4px; }
105
+ .tool-call .tool-name { color: var(--cyan); }
106
+ .tool-call .tool-status { margin-left: 8px; }
107
+ .tool-call .tool-status.completed { color: var(--green); }
108
+ .tool-call .tool-status.failed { color: var(--red); }
109
+ .tool-call .tool-status.in_progress { color: var(--yellow); }
110
+
111
+ /* Tool call content (expandable) */
112
+ .tool-body { display: none; margin-top: 4px; }
113
+ .tool-call.expanded .tool-body { display: block; }
114
+
115
+ /* Diff blocks */
116
+ .diff { margin: 4px 0; font-size: 12px; }
117
+ .diff-header { color: var(--text-dim); }
118
+ .diff-add { color: var(--green); }
119
+ .diff-add::before { content: '+ '; }
120
+ .diff-del { color: var(--red); }
121
+ .diff-del::before { content: '- '; }
122
+
123
+ /* Code blocks */
124
+ .code-block {
125
+ background: var(--bg-tool);
126
+ border: 1px solid var(--border);
127
+ border-radius: 4px;
128
+ padding: 6px 8px;
129
+ margin: 4px 0;
130
+ overflow-x: auto;
131
+ font-size: 12px;
132
+ }
133
+ .code-header { color: var(--text-dim); font-size: 11px; margin-bottom: 2px; }
134
+
135
+ /* Permission dialog */
136
+ #permission-overlay {
137
+ position: fixed;
138
+ top: 0; left: 0; right: 0; bottom: 0;
139
+ background: rgba(0,0,0,0.7);
140
+ display: flex;
141
+ align-items: flex-end;
142
+ justify-content: center;
143
+ padding-bottom: 60px;
144
+ z-index: 100;
145
+ }
146
+ #permission-overlay.hidden { display: none; }
147
+ .perm-dialog {
148
+ background: var(--bg-tool);
149
+ border: 1px solid var(--yellow);
150
+ border-radius: 8px;
151
+ padding: 12px;
152
+ width: calc(100% - 24px);
153
+ max-width: 400px;
154
+ max-height: 40vh;
155
+ display: flex;
156
+ flex-direction: column;
157
+ }
158
+ .perm-dialog h3 { color: var(--yellow); font-size: 14px; margin-bottom: 6px; flex-shrink: 0; }
159
+ .perm-dialog p { font-size: 12px; color: var(--text); margin-bottom: 10px; overflow-y: auto; flex: 1; min-height: 0; }
160
+ .perm-actions { display: flex; gap: 8px; justify-content: flex-end; flex-shrink: 0; padding-top: 4px; }
161
+ .perm-actions { display: flex; gap: 8px; justify-content: flex-end; }
162
+ .perm-actions button {
163
+ padding: 6px 16px; border: none; border-radius: 4px;
164
+ cursor: pointer; font-size: 13px; font-family: var(--font);
165
+ }
166
+ .btn-approve { background: var(--green); color: #000; }
167
+ .btn-deny { background: var(--red); color: #fff; }
168
+
169
+ /* Input area */
170
+ #input-area {
171
+ padding: 4px 8px 6px;
172
+ background: var(--bg-tool);
173
+ border-top: 1px solid var(--border);
174
+ flex-shrink: 0;
175
+ }
176
+ #key-bar {
177
+ display: flex;
178
+ gap: 4px;
179
+ padding: 4px 0;
180
+ overflow-x: auto;
181
+ -webkit-overflow-scrolling: touch;
182
+ }
183
+ #key-bar button {
184
+ background: var(--bg);
185
+ border: 1px solid var(--border);
186
+ color: var(--text);
187
+ font-family: var(--font);
188
+ font-size: 13px;
189
+ padding: 6px 10px;
190
+ border-radius: 4px;
191
+ cursor: pointer;
192
+ flex-shrink: 0;
193
+ min-width: 36px;
194
+ -webkit-tap-highlight-color: transparent;
195
+ }
196
+ #key-bar button:active { background: var(--blue); color: #000; }
197
+ #input-form {
198
+ display: flex;
199
+ align-items: center;
200
+ gap: 4px;
201
+ }
202
+ .prompt { color: var(--green); font-weight: bold; }
203
+ #input {
204
+ flex: 1;
205
+ background: transparent;
206
+ border: none;
207
+ color: var(--text-bright);
208
+ font-size: 14px;
209
+ font-family: var(--font);
210
+ outline: none;
211
+ caret-color: var(--green);
212
+ }
213
+ #input::placeholder { color: var(--text-dim); }
214
+
215
+ .hidden { display: none !important; }
216
+
217
+ /* Dashboard */
218
+ #dashboard {
219
+ flex: 1;
220
+ overflow-y: auto;
221
+ padding: 8px;
222
+ }
223
+ .session-card {
224
+ background: var(--bg-tool);
225
+ border: 1px solid var(--border);
226
+ border-radius: 6px;
227
+ padding: 10px 12px;
228
+ margin-bottom: 6px;
229
+ cursor: pointer;
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 10px;
233
+ }
234
+ .session-card:hover { border-color: var(--blue); }
235
+ .session-card .status-dot {
236
+ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
237
+ }
238
+ .session-card .status-dot.online { background: var(--green); }
239
+ .session-card .status-dot.offline { background: var(--text-dim); }
240
+ .session-card .info { flex: 1; min-width: 0; }
241
+ .session-card .repo { color: var(--blue); font-weight: bold; font-size: 13px; }
242
+ .session-card .branch { color: var(--text-dim); font-size: 11px; }
243
+ .session-card .machine { color: var(--text-dim); font-size: 11px; }
244
+ .session-card .arrow { color: var(--text-dim); }
245
+
246
+ /* Scrollbar */
247
+ ::-webkit-scrollbar { width: 6px; }
248
+ ::-webkit-scrollbar-track { background: transparent; }
249
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }