minivibe 0.1.3 → 0.2.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 +21 -0
- package/README.md +55 -21
- package/agent/agent.js +327 -28
- package/e2e.js +677 -0
- package/package.json +13 -10
- package/pty-wrapper-node.js +18 -0
- package/pty-wrapper.py +17 -0
- package/vibe.js +1213 -309
- package/GETTING_STARTED.md +0 -241
- package/login.html +0 -331
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2025 MiniVibe
|
|
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
CHANGED
|
@@ -11,6 +11,7 @@ CLI wrapper for Claude Code with mobile remote control via MiniVibe iOS app.
|
|
|
11
11
|
- Token usage tracking
|
|
12
12
|
- Headless authentication for servers (EC2, etc.)
|
|
13
13
|
- Skip permissions mode for automation
|
|
14
|
+
- **End-to-end encryption** - Optional E2E encryption so bridge server cannot read message content
|
|
14
15
|
|
|
15
16
|
## Quick Start
|
|
16
17
|
|
|
@@ -23,15 +24,13 @@ vibe --login # Desktop (opens browser)
|
|
|
23
24
|
vibe --login --headless # Server/EC2 (device code)
|
|
24
25
|
|
|
25
26
|
# Option 1: Direct bridge connection
|
|
26
|
-
vibe --bridge wss://ws.
|
|
27
|
+
vibe --bridge wss://ws.minivibeapp.com
|
|
27
28
|
|
|
28
29
|
# Option 2: Agent mode (recommended for servers)
|
|
29
|
-
vibe-agent --bridge wss://ws.
|
|
30
|
+
vibe-agent --bridge wss://ws.minivibeapp.com & # Start agent
|
|
30
31
|
vibe --agent # Create sessions
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
See [GETTING_STARTED.md](GETTING_STARTED.md) for detailed setup instructions.
|
|
34
|
-
|
|
35
34
|
## Installation
|
|
36
35
|
|
|
37
36
|
### From npm (Recommended)
|
|
@@ -47,8 +46,8 @@ This installs two commands:
|
|
|
47
46
|
### From Source
|
|
48
47
|
|
|
49
48
|
```bash
|
|
50
|
-
git clone https://github.com/
|
|
51
|
-
cd
|
|
49
|
+
git clone https://github.com/minivibeapp/minivibe.git
|
|
50
|
+
cd minivibe
|
|
52
51
|
npm install
|
|
53
52
|
npm link
|
|
54
53
|
```
|
|
@@ -86,8 +85,8 @@ Get token from MiniVibe iOS app: Settings > Copy Token for CLI.
|
|
|
86
85
|
Connect directly to the bridge server:
|
|
87
86
|
|
|
88
87
|
```bash
|
|
89
|
-
vibe --bridge wss://ws.
|
|
90
|
-
vibe --bridge wss://ws.
|
|
88
|
+
vibe --bridge wss://ws.minivibeapp.com
|
|
89
|
+
vibe --bridge wss://ws.minivibeapp.com "Fix the bug in main.js"
|
|
91
90
|
```
|
|
92
91
|
|
|
93
92
|
### Agent Mode (Recommended for Servers)
|
|
@@ -96,7 +95,7 @@ Use a local agent to manage sessions:
|
|
|
96
95
|
|
|
97
96
|
```bash
|
|
98
97
|
# Terminal 1: Start the agent (runs continuously)
|
|
99
|
-
vibe-agent --bridge wss://ws.
|
|
98
|
+
vibe-agent --bridge wss://ws.minivibeapp.com
|
|
100
99
|
|
|
101
100
|
# Terminal 2+: Create sessions via agent
|
|
102
101
|
vibe --agent
|
|
@@ -128,12 +127,16 @@ vibe "Explain this code" # With prompt
|
|
|
128
127
|
| `--bridge <url>` | Connect to bridge server |
|
|
129
128
|
| `--agent [url]` | Connect via local vibe-agent (default: auto-discover) |
|
|
130
129
|
| `--name <name>` | Name this session (shown in mobile app) |
|
|
131
|
-
| `--resume <id>` | Resume a previous session |
|
|
130
|
+
| `--resume <id>` | Resume a previous session (auto-detects directory) |
|
|
131
|
+
| `--attach <id>` | Attach to running session via local agent |
|
|
132
|
+
| `--remote <id>` | Remote control session via bridge (no local Claude needed) |
|
|
133
|
+
| `--list` | List running sessions on local agent |
|
|
132
134
|
| `--login` | Sign in with Google |
|
|
133
135
|
| `--headless` | Use device code flow for headless environments |
|
|
134
136
|
| `--token <token>` | Set Firebase auth token manually |
|
|
135
137
|
| `--logout` | Remove stored auth token |
|
|
136
138
|
| `--dangerously-skip-permissions` | Auto-approve all tool executions |
|
|
139
|
+
| `--e2e` | Enable end-to-end encryption (auto key exchange with iOS) |
|
|
137
140
|
| `--node-pty` | Use Node.js PTY wrapper (required for Windows) |
|
|
138
141
|
| `--help, -h` | Show help message |
|
|
139
142
|
|
|
@@ -142,29 +145,59 @@ vibe "Explain this code" # With prompt
|
|
|
142
145
|
| Option | Description |
|
|
143
146
|
|--------|-------------|
|
|
144
147
|
| `--bridge <url>` | Bridge server URL (required) |
|
|
145
|
-
| `--
|
|
148
|
+
| `--login` | Start device code login flow |
|
|
149
|
+
| `--token <token>` | Use specific Firebase token |
|
|
150
|
+
| `--name <name>` | Set host display name |
|
|
151
|
+
| `--status` | Show current status and exit |
|
|
146
152
|
| `--help, -h` | Show help message |
|
|
147
153
|
|
|
148
|
-
## In-Session Commands
|
|
149
|
-
|
|
150
|
-
| Command | Description |
|
|
151
|
-
|---------|-------------|
|
|
152
|
-
| `/name <name>` | Rename the current session |
|
|
153
|
-
| `/path` + Enter | If not a vibe command, forwards to Claude |
|
|
154
|
-
| `Escape` | Cancel command mode, forward to Claude |
|
|
155
|
-
| `Ctrl+C` | Cancel command mode, forward to Claude |
|
|
156
|
-
|
|
157
154
|
## Skip Permissions Mode
|
|
158
155
|
|
|
159
156
|
For automated/headless environments where you trust the execution context:
|
|
160
157
|
|
|
161
158
|
```bash
|
|
162
|
-
vibe --dangerously-skip-permissions --bridge wss://ws.
|
|
159
|
+
vibe --dangerously-skip-permissions --bridge wss://ws.minivibeapp.com
|
|
163
160
|
vibe --dangerously-skip-permissions --agent
|
|
164
161
|
```
|
|
165
162
|
|
|
166
163
|
**Warning:** This mode auto-approves ALL tool executions (commands, file writes, etc.) without prompting. Only use in trusted/sandboxed environments.
|
|
167
164
|
|
|
165
|
+
## End-to-End Encryption
|
|
166
|
+
|
|
167
|
+
Enable E2E encryption to ensure the bridge server cannot read your message content:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# Start with E2E encryption enabled
|
|
171
|
+
vibe --e2e --bridge wss://ws.minivibeapp.com
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Key exchange happens automatically when both CLI and iOS connect to the bridge:
|
|
175
|
+
|
|
176
|
+
1. Enable E2E in MiniVibe iOS app: **Settings > Security > E2E Encryption**
|
|
177
|
+
2. Start CLI with `--e2e` flag
|
|
178
|
+
3. Both sides exchange public keys automatically on connect
|
|
179
|
+
4. Encryption is established - no QR scanning needed!
|
|
180
|
+
|
|
181
|
+
### How It Works
|
|
182
|
+
|
|
183
|
+
- Uses **X25519** key exchange (same as Signal, WhatsApp)
|
|
184
|
+
- Messages encrypted with **AES-256-GCM**
|
|
185
|
+
- Keys derived using **HKDF-SHA256**
|
|
186
|
+
- Bridge server sees message routing info but cannot read content
|
|
187
|
+
|
|
188
|
+
### Key Storage
|
|
189
|
+
|
|
190
|
+
| Location | Description |
|
|
191
|
+
|----------|-------------|
|
|
192
|
+
| `~/.vibe/e2e-keys.json` | CLI keypair and peer info |
|
|
193
|
+
| iOS Keychain | iOS keypair and peer info |
|
|
194
|
+
|
|
195
|
+
### Security Notes
|
|
196
|
+
|
|
197
|
+
- E2E is optional and backward compatible
|
|
198
|
+
- Once paired, encryption persists across sessions
|
|
199
|
+
- To re-pair: delete `~/.vibe/e2e-keys.json` and reset in iOS Settings
|
|
200
|
+
|
|
168
201
|
## Architecture
|
|
169
202
|
|
|
170
203
|
```
|
|
@@ -206,6 +239,7 @@ May also need Visual Studio Build Tools and Python for native compilation.
|
|
|
206
239
|
|------|-------------|
|
|
207
240
|
| `~/.vibe/auth.json` | Stored authentication (token + refresh token) |
|
|
208
241
|
| `~/.vibe/token` | Legacy token file |
|
|
242
|
+
| `~/.vibe/e2e-keys.json` | E2E encryption keypair and peer info |
|
|
209
243
|
| `~/.vibe-agent/port` | Agent port file for auto-discovery |
|
|
210
244
|
|
|
211
245
|
## License
|
package/agent/agent.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* - Stop running sessions
|
|
10
10
|
*
|
|
11
11
|
* Usage:
|
|
12
|
-
* vibe-agent --bridge wss://ws.
|
|
13
|
-
* vibe-agent --login --bridge wss://ws.
|
|
12
|
+
* vibe-agent --bridge wss://ws.minivibeapp.com --token <firebase-token>
|
|
13
|
+
* vibe-agent --login --bridge wss://ws.minivibeapp.com
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const { spawn, execSync } = require('child_process');
|
|
@@ -28,11 +28,13 @@ const os = require('os');
|
|
|
28
28
|
const CONFIG_DIR = path.join(os.homedir(), '.vibe-agent');
|
|
29
29
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
30
30
|
const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
|
|
31
|
+
const SESSION_HISTORY_FILE = path.join(CONFIG_DIR, 'session-history.json');
|
|
31
32
|
|
|
32
33
|
const RECONNECT_DELAY_MS = 5000;
|
|
33
34
|
const HEARTBEAT_INTERVAL_MS = 30000;
|
|
34
35
|
const LOCAL_SERVER_PORT = 9999;
|
|
35
36
|
const PORT_FILE = path.join(os.homedir(), '.vibe-agent', 'port');
|
|
37
|
+
const MAX_SESSION_HISTORY_AGE_DAYS = 30;
|
|
36
38
|
|
|
37
39
|
// Colors for terminal output
|
|
38
40
|
const colors = {
|
|
@@ -166,9 +168,53 @@ let heartbeatTimer = null;
|
|
|
166
168
|
// Track running sessions: sessionId -> { process, path, name, localWs }
|
|
167
169
|
const runningSessions = new Map();
|
|
168
170
|
|
|
171
|
+
// Track session history for resume: sessionId -> { path, name, endedAt }
|
|
172
|
+
// Kept even after session ends so we can resume with correct path
|
|
173
|
+
// Persisted to disk so it survives agent restarts
|
|
174
|
+
const MAX_SESSION_HISTORY = 100;
|
|
175
|
+
const sessionHistory = loadSessionHistoryFromDisk();
|
|
176
|
+
|
|
169
177
|
// Track sessions being intentionally stopped (to distinguish from unexpected disconnects)
|
|
170
178
|
const stoppingSessions = new Set();
|
|
171
179
|
|
|
180
|
+
// Load session history from disk on startup
|
|
181
|
+
function loadSessionHistoryFromDisk() {
|
|
182
|
+
try {
|
|
183
|
+
if (fs.existsSync(SESSION_HISTORY_FILE)) {
|
|
184
|
+
const data = JSON.parse(fs.readFileSync(SESSION_HISTORY_FILE, 'utf8'));
|
|
185
|
+
const map = new Map();
|
|
186
|
+
const cutoffTime = Date.now() - (MAX_SESSION_HISTORY_AGE_DAYS * 24 * 60 * 60 * 1000);
|
|
187
|
+
|
|
188
|
+
// Filter out entries older than MAX_SESSION_HISTORY_AGE_DAYS
|
|
189
|
+
for (const [sessionId, info] of Object.entries(data)) {
|
|
190
|
+
if (new Date(info.endedAt).getTime() >= cutoffTime) {
|
|
191
|
+
map.set(sessionId, info);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log(`[vibe-agent] Loaded ${map.size} sessions from history`);
|
|
196
|
+
return map;
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.log(`[vibe-agent] Failed to load session history: ${err.message}`);
|
|
200
|
+
}
|
|
201
|
+
return new Map();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Save session history to disk
|
|
205
|
+
function saveSessionHistoryToDisk() {
|
|
206
|
+
try {
|
|
207
|
+
// Ensure config directory exists
|
|
208
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
209
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
210
|
+
}
|
|
211
|
+
const data = Object.fromEntries(sessionHistory);
|
|
212
|
+
fs.writeFileSync(SESSION_HISTORY_FILE, JSON.stringify(data, null, 2));
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.log(`[vibe-agent] Failed to save session history: ${err.message}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
172
218
|
// Local server for vibe-cli connections
|
|
173
219
|
let localServer = null;
|
|
174
220
|
// Track local CLI connections: ws -> { sessionId, authenticated }
|
|
@@ -310,18 +356,48 @@ function startLocalServer() {
|
|
|
310
356
|
const clientInfo = localClients.get(clientWs);
|
|
311
357
|
if (clientInfo?.sessionId) {
|
|
312
358
|
const sessionId = clientInfo.sessionId;
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
359
|
+
|
|
360
|
+
// Check if this is an attached client (not the session owner)
|
|
361
|
+
if (clientInfo.isAttached) {
|
|
362
|
+
const session = runningSessions.get(sessionId);
|
|
363
|
+
if (session?.attachedClients) {
|
|
364
|
+
session.attachedClients.delete(clientWs);
|
|
365
|
+
log(`Attached client disconnected from session ${sessionId.slice(0, 8)}`, colors.dim);
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
// This is the session owner - end the session
|
|
369
|
+
const wasIntentionalStop = stoppingSessions.has(sessionId);
|
|
370
|
+
stoppingSessions.delete(sessionId); // Clean up
|
|
371
|
+
|
|
372
|
+
log(`Local session ${sessionId.slice(0, 8)} ${wasIntentionalStop ? 'stopped' : 'disconnected'}`, colors.dim);
|
|
373
|
+
// Save to history before deleting for resume capability
|
|
374
|
+
const session = runningSessions.get(sessionId);
|
|
375
|
+
if (session) {
|
|
376
|
+
saveSessionHistory(sessionId, session.path, session.name);
|
|
377
|
+
// Notify attached clients that session ended
|
|
378
|
+
if (session.attachedClients) {
|
|
379
|
+
for (const attachedWs of session.attachedClients) {
|
|
380
|
+
try {
|
|
381
|
+
attachedWs.send(JSON.stringify({
|
|
382
|
+
type: 'session_ended',
|
|
383
|
+
sessionId,
|
|
384
|
+
reason: wasIntentionalStop ? 'stopped_by_user' : 'disconnected'
|
|
385
|
+
}));
|
|
386
|
+
} catch (err) {
|
|
387
|
+
// Ignore
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
runningSessions.delete(sessionId);
|
|
393
|
+
// Notify bridge
|
|
394
|
+
send({
|
|
395
|
+
type: 'agent_session_ended',
|
|
396
|
+
sessionId: sessionId,
|
|
397
|
+
exitCode: 0,
|
|
398
|
+
reason: wasIntentionalStop ? 'stopped_by_user' : 'disconnected'
|
|
399
|
+
});
|
|
400
|
+
}
|
|
325
401
|
}
|
|
326
402
|
localClients.delete(clientWs);
|
|
327
403
|
});
|
|
@@ -385,7 +461,8 @@ function handleLocalMessage(clientWs, msg) {
|
|
|
385
461
|
path: msg.path || process.cwd(),
|
|
386
462
|
name: msg.name || path.basename(msg.path || process.cwd()),
|
|
387
463
|
startedAt: new Date().toISOString(),
|
|
388
|
-
managed: true // Indicates connected via local server, not spawned
|
|
464
|
+
managed: true, // Indicates connected via local server, not spawned
|
|
465
|
+
attachedClients: new Set() // Track attached terminal clients
|
|
389
466
|
});
|
|
390
467
|
|
|
391
468
|
log(`Local session registered: ${sessionId.slice(0, 8)} (${msg.name || msg.path})`, colors.green);
|
|
@@ -411,13 +488,135 @@ function handleLocalMessage(clientWs, msg) {
|
|
|
411
488
|
}
|
|
412
489
|
break;
|
|
413
490
|
|
|
414
|
-
//
|
|
491
|
+
// Attach to existing session (terminal mirroring)
|
|
492
|
+
case 'attach_session':
|
|
493
|
+
const attachSessionId = msg.sessionId;
|
|
494
|
+
|
|
495
|
+
// Validate sessionId
|
|
496
|
+
if (!attachSessionId) {
|
|
497
|
+
clientWs.send(JSON.stringify({
|
|
498
|
+
type: 'attach_error',
|
|
499
|
+
error: 'sessionId is required'
|
|
500
|
+
}));
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Check if session is stopping
|
|
505
|
+
if (stoppingSessions.has(attachSessionId)) {
|
|
506
|
+
log(`Attach failed: session ${attachSessionId.slice(0, 8)} is stopping`, colors.yellow);
|
|
507
|
+
clientWs.send(JSON.stringify({
|
|
508
|
+
type: 'attach_error',
|
|
509
|
+
sessionId: attachSessionId,
|
|
510
|
+
error: 'Session is currently stopping. Cannot attach.'
|
|
511
|
+
}));
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const session = runningSessions.get(attachSessionId);
|
|
516
|
+
|
|
517
|
+
if (!session) {
|
|
518
|
+
log(`Attach failed: session ${attachSessionId.slice(0, 8)} not found`, colors.red);
|
|
519
|
+
clientWs.send(JSON.stringify({
|
|
520
|
+
type: 'attach_error',
|
|
521
|
+
sessionId: attachSessionId,
|
|
522
|
+
error: 'Session not found. It may have ended or is running on a different agent.'
|
|
523
|
+
}));
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Mark this client as attached to the session
|
|
528
|
+
clientInfo.sessionId = attachSessionId;
|
|
529
|
+
clientInfo.isAttached = true;
|
|
530
|
+
|
|
531
|
+
// Add to session's attached clients
|
|
532
|
+
if (!session.attachedClients) {
|
|
533
|
+
session.attachedClients = new Set();
|
|
534
|
+
}
|
|
535
|
+
session.attachedClients.add(clientWs);
|
|
536
|
+
|
|
537
|
+
log(`Client attached to session ${attachSessionId.slice(0, 8)} (${session.attachedClients.size} attached)`, colors.cyan);
|
|
538
|
+
|
|
539
|
+
clientWs.send(JSON.stringify({
|
|
540
|
+
type: 'attach_success',
|
|
541
|
+
sessionId: attachSessionId,
|
|
542
|
+
name: session.name,
|
|
543
|
+
path: session.path
|
|
544
|
+
}));
|
|
545
|
+
break;
|
|
546
|
+
|
|
547
|
+
// List running sessions
|
|
548
|
+
case 'list_sessions':
|
|
549
|
+
const sessionsList = [];
|
|
550
|
+
for (const [sessionId, session] of runningSessions) {
|
|
551
|
+
// Determine source: spawned sessions have 'process', managed have 'localWs'
|
|
552
|
+
// Sessions started from iOS come via bridge with spawn, sessions from CLI use --agent
|
|
553
|
+
const source = session.process ? 'ios' : 'cli';
|
|
554
|
+
sessionsList.push({
|
|
555
|
+
sessionId,
|
|
556
|
+
name: session.name,
|
|
557
|
+
path: session.path,
|
|
558
|
+
startedAt: session.startedAt,
|
|
559
|
+
source
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
clientWs.send(JSON.stringify({
|
|
563
|
+
type: 'sessions_list',
|
|
564
|
+
sessions: sessionsList
|
|
565
|
+
}));
|
|
566
|
+
log(`Listed ${sessionsList.length} running sessions`, colors.dim);
|
|
567
|
+
break;
|
|
568
|
+
|
|
569
|
+
// Terminal input from attached client - forward to session provider
|
|
570
|
+
case 'terminal_input':
|
|
571
|
+
const targetSession = runningSessions.get(clientInfo.sessionId);
|
|
572
|
+
if (targetSession && targetSession.localWs && clientInfo.isAttached) {
|
|
573
|
+
// Forward input to the session's provider (the original vibe-cli)
|
|
574
|
+
try {
|
|
575
|
+
targetSession.localWs.send(JSON.stringify({
|
|
576
|
+
type: 'terminal_input',
|
|
577
|
+
data: msg.data
|
|
578
|
+
}));
|
|
579
|
+
} catch (err) {
|
|
580
|
+
log(`Failed to forward terminal input: ${err.message}`, colors.red);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
break;
|
|
584
|
+
|
|
585
|
+
// Terminal output from session provider - relay to attached clients
|
|
586
|
+
case 'terminal_output':
|
|
587
|
+
const outputSession = runningSessions.get(clientInfo.sessionId);
|
|
588
|
+
if (outputSession?.attachedClients) {
|
|
589
|
+
for (const attachedWs of outputSession.attachedClients) {
|
|
590
|
+
try {
|
|
591
|
+
attachedWs.send(JSON.stringify(msg));
|
|
592
|
+
} catch (err) {
|
|
593
|
+
// Ignore send errors
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
break;
|
|
598
|
+
|
|
599
|
+
// All other messages: relay to bridge and also to attached clients
|
|
415
600
|
default:
|
|
416
601
|
// Add sessionId if not present
|
|
417
602
|
if (!msg.sessionId && clientInfo.sessionId) {
|
|
418
603
|
msg.sessionId = clientInfo.sessionId;
|
|
419
604
|
}
|
|
420
605
|
|
|
606
|
+
// Relay certain message types to attached clients for terminal mirroring
|
|
607
|
+
if (['claude_message', 'permission_request', 'session_status'].includes(msg.type)) {
|
|
608
|
+
const msgSession = runningSessions.get(clientInfo.sessionId);
|
|
609
|
+
if (msgSession?.attachedClients) {
|
|
610
|
+
for (const attachedWs of msgSession.attachedClients) {
|
|
611
|
+
try {
|
|
612
|
+
attachedWs.send(JSON.stringify(msg));
|
|
613
|
+
} catch (err) {
|
|
614
|
+
// Ignore send errors
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
421
620
|
if (send(msg)) {
|
|
422
621
|
// Message relayed successfully
|
|
423
622
|
} else {
|
|
@@ -613,6 +812,32 @@ function findVibeCli() {
|
|
|
613
812
|
}
|
|
614
813
|
}
|
|
615
814
|
|
|
815
|
+
// Save session info to history for resume capability
|
|
816
|
+
function saveSessionHistory(sessionId, sessionPath, sessionName) {
|
|
817
|
+
// Limit history size
|
|
818
|
+
if (sessionHistory.size >= MAX_SESSION_HISTORY) {
|
|
819
|
+
// Delete oldest entry
|
|
820
|
+
const oldest = sessionHistory.keys().next().value;
|
|
821
|
+
sessionHistory.delete(oldest);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
sessionHistory.set(sessionId, {
|
|
825
|
+
path: sessionPath,
|
|
826
|
+
name: sessionName,
|
|
827
|
+
endedAt: new Date().toISOString()
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
// Persist to disk
|
|
831
|
+
saveSessionHistoryToDisk();
|
|
832
|
+
|
|
833
|
+
log(`Saved session ${sessionId.slice(0, 8)} to history (path: ${sessionPath})`, colors.dim);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Get session info from history
|
|
837
|
+
function getSessionFromHistory(sessionId) {
|
|
838
|
+
return sessionHistory.get(sessionId);
|
|
839
|
+
}
|
|
840
|
+
|
|
616
841
|
function handleStartSession(msg) {
|
|
617
842
|
const { sessionId, path: projectPath, name, prompt, requestId } = msg;
|
|
618
843
|
|
|
@@ -675,7 +900,8 @@ function handleStartSession(msg) {
|
|
|
675
900
|
process: proc,
|
|
676
901
|
path: cwd,
|
|
677
902
|
name: name || path.basename(cwd),
|
|
678
|
-
startedAt: new Date().toISOString()
|
|
903
|
+
startedAt: new Date().toISOString(),
|
|
904
|
+
attachedClients: new Set() // Support terminal attachment
|
|
679
905
|
});
|
|
680
906
|
|
|
681
907
|
proc.stdout.on('data', (data) => {
|
|
@@ -695,6 +921,11 @@ function handleStartSession(msg) {
|
|
|
695
921
|
|
|
696
922
|
proc.on('exit', (code) => {
|
|
697
923
|
log(`Session ${newSessionId.slice(0, 8)} exited with code ${code}`, colors.dim);
|
|
924
|
+
// Save to history before deleting for resume capability
|
|
925
|
+
const session = runningSessions.get(newSessionId);
|
|
926
|
+
if (session) {
|
|
927
|
+
saveSessionHistory(newSessionId, session.path, session.name);
|
|
928
|
+
}
|
|
698
929
|
runningSessions.delete(newSessionId);
|
|
699
930
|
|
|
700
931
|
send({
|
|
@@ -706,6 +937,11 @@ function handleStartSession(msg) {
|
|
|
706
937
|
|
|
707
938
|
proc.on('error', (err) => {
|
|
708
939
|
log(`Session error: ${err.message}`, colors.red);
|
|
940
|
+
// Save to history before deleting for resume capability
|
|
941
|
+
const session = runningSessions.get(newSessionId);
|
|
942
|
+
if (session) {
|
|
943
|
+
saveSessionHistory(newSessionId, session.path, session.name);
|
|
944
|
+
}
|
|
709
945
|
runningSessions.delete(newSessionId);
|
|
710
946
|
|
|
711
947
|
send({
|
|
@@ -741,6 +977,17 @@ function handleStartSession(msg) {
|
|
|
741
977
|
function handleResumeSession(msg) {
|
|
742
978
|
const { sessionId, path: projectPath, name, requestId } = msg;
|
|
743
979
|
|
|
980
|
+
// Validate sessionId is present
|
|
981
|
+
if (!sessionId) {
|
|
982
|
+
log('Resume session called without sessionId', colors.red);
|
|
983
|
+
send({
|
|
984
|
+
type: 'agent_session_error',
|
|
985
|
+
requestId,
|
|
986
|
+
error: 'sessionId is required to resume a session'
|
|
987
|
+
});
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
744
991
|
log(`Resuming session: ${sessionId.slice(0, 8)}`, colors.cyan);
|
|
745
992
|
|
|
746
993
|
// Check if already running
|
|
@@ -755,6 +1002,18 @@ function handleResumeSession(msg) {
|
|
|
755
1002
|
return;
|
|
756
1003
|
}
|
|
757
1004
|
|
|
1005
|
+
// Check if session is currently being stopped
|
|
1006
|
+
if (stoppingSessions.has(sessionId)) {
|
|
1007
|
+
log('Session is currently stopping', colors.yellow);
|
|
1008
|
+
send({
|
|
1009
|
+
type: 'agent_session_error',
|
|
1010
|
+
requestId,
|
|
1011
|
+
sessionId,
|
|
1012
|
+
error: 'Session is currently stopping. Please wait a moment and try again.'
|
|
1013
|
+
});
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
758
1017
|
const vibeCli = findVibeCli();
|
|
759
1018
|
if (!vibeCli) {
|
|
760
1019
|
log('vibe-cli not found!', colors.red);
|
|
@@ -770,12 +1029,37 @@ function handleResumeSession(msg) {
|
|
|
770
1029
|
// Build args with --resume - use --agent to connect via local server
|
|
771
1030
|
const args = ['--agent', `ws://localhost:${LOCAL_SERVER_PORT}`, '--resume', sessionId];
|
|
772
1031
|
|
|
773
|
-
if
|
|
774
|
-
|
|
1032
|
+
// Try to get session info from history if path not provided
|
|
1033
|
+
let effectivePath = projectPath;
|
|
1034
|
+
let effectiveName = name;
|
|
1035
|
+
|
|
1036
|
+
if (!effectivePath) {
|
|
1037
|
+
const historyEntry = getSessionFromHistory(sessionId);
|
|
1038
|
+
if (historyEntry) {
|
|
1039
|
+
effectivePath = historyEntry.path;
|
|
1040
|
+
effectiveName = effectiveName || historyEntry.name;
|
|
1041
|
+
log(`Using path from session history: ${effectivePath}`, colors.dim);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Don't silently fall back to home directory - require a valid path
|
|
1046
|
+
if (!effectivePath) {
|
|
1047
|
+
log(`Cannot resume session ${sessionId.slice(0, 8)}: path unknown`, colors.red);
|
|
1048
|
+
send({
|
|
1049
|
+
type: 'agent_session_error',
|
|
1050
|
+
requestId,
|
|
1051
|
+
sessionId,
|
|
1052
|
+
error: 'Cannot resume session: working directory path unknown. The session may have been created before path tracking was enabled, or the agent was restarted.'
|
|
1053
|
+
});
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (effectiveName) {
|
|
1058
|
+
args.push('--name', effectiveName);
|
|
775
1059
|
}
|
|
776
1060
|
|
|
777
1061
|
// Spawn vibe-cli with resume - expand ~ to home directory
|
|
778
|
-
let cwd =
|
|
1062
|
+
let cwd = effectivePath;
|
|
779
1063
|
if (cwd.startsWith('~')) {
|
|
780
1064
|
cwd = cwd.replace(/^~/, os.homedir());
|
|
781
1065
|
}
|
|
@@ -804,8 +1088,9 @@ function handleResumeSession(msg) {
|
|
|
804
1088
|
runningSessions.set(sessionId, {
|
|
805
1089
|
process: proc,
|
|
806
1090
|
path: cwd,
|
|
807
|
-
name:
|
|
808
|
-
startedAt: new Date().toISOString()
|
|
1091
|
+
name: effectiveName || path.basename(cwd),
|
|
1092
|
+
startedAt: new Date().toISOString(),
|
|
1093
|
+
attachedClients: new Set() // Support terminal attachment
|
|
809
1094
|
});
|
|
810
1095
|
|
|
811
1096
|
proc.stdout.on('data', (data) => {
|
|
@@ -824,6 +1109,11 @@ function handleResumeSession(msg) {
|
|
|
824
1109
|
|
|
825
1110
|
proc.on('exit', (code) => {
|
|
826
1111
|
log(`Session ${sessionId.slice(0, 8)} exited with code ${code}`, colors.dim);
|
|
1112
|
+
// Save to history before deleting for resume capability
|
|
1113
|
+
const session = runningSessions.get(sessionId);
|
|
1114
|
+
if (session) {
|
|
1115
|
+
saveSessionHistory(sessionId, session.path, session.name);
|
|
1116
|
+
}
|
|
827
1117
|
runningSessions.delete(sessionId);
|
|
828
1118
|
|
|
829
1119
|
send({
|
|
@@ -835,6 +1125,11 @@ function handleResumeSession(msg) {
|
|
|
835
1125
|
|
|
836
1126
|
proc.on('error', (err) => {
|
|
837
1127
|
log(`Session error: ${err.message}`, colors.red);
|
|
1128
|
+
// Save to history before deleting for resume capability
|
|
1129
|
+
const session = runningSessions.get(sessionId);
|
|
1130
|
+
if (session) {
|
|
1131
|
+
saveSessionHistory(sessionId, session.path, session.name);
|
|
1132
|
+
}
|
|
838
1133
|
runningSessions.delete(sessionId);
|
|
839
1134
|
|
|
840
1135
|
send({
|
|
@@ -851,7 +1146,7 @@ function handleResumeSession(msg) {
|
|
|
851
1146
|
requestId,
|
|
852
1147
|
sessionId,
|
|
853
1148
|
path: cwd,
|
|
854
|
-
name:
|
|
1149
|
+
name: effectiveName || path.basename(cwd)
|
|
855
1150
|
});
|
|
856
1151
|
|
|
857
1152
|
log(`Session resumed: ${sessionId.slice(0, 8)}`, colors.green);
|
|
@@ -920,6 +1215,10 @@ function handleStopSession(msg) {
|
|
|
920
1215
|
} catch (err) {
|
|
921
1216
|
// Already closed
|
|
922
1217
|
}
|
|
1218
|
+
// Cleanup stoppingSessions as backup in case websocket close event doesn't fire
|
|
1219
|
+
setTimeout(() => {
|
|
1220
|
+
stoppingSessions.delete(sessionId);
|
|
1221
|
+
}, 5000);
|
|
923
1222
|
}, 100);
|
|
924
1223
|
runningSessions.delete(sessionId);
|
|
925
1224
|
}
|
|
@@ -1025,7 +1324,7 @@ ${colors.bold}Usage:${colors.reset}
|
|
|
1025
1324
|
vibe-agent --status Show agent status
|
|
1026
1325
|
|
|
1027
1326
|
${colors.bold}Options:${colors.reset}
|
|
1028
|
-
--bridge <url> Bridge server URL (wss://ws.
|
|
1327
|
+
--bridge <url> Bridge server URL (wss://ws.minivibeapp.com)
|
|
1029
1328
|
--login Start device code login flow
|
|
1030
1329
|
--token <token> Use specific Firebase token
|
|
1031
1330
|
--name <name> Set host display name
|
|
@@ -1034,13 +1333,13 @@ ${colors.bold}Options:${colors.reset}
|
|
|
1034
1333
|
|
|
1035
1334
|
${colors.bold}Examples:${colors.reset}
|
|
1036
1335
|
# Login (first time)
|
|
1037
|
-
vibe-agent --login --bridge wss://ws.
|
|
1336
|
+
vibe-agent --login --bridge wss://ws.minivibeapp.com
|
|
1038
1337
|
|
|
1039
1338
|
# Start agent daemon
|
|
1040
|
-
vibe-agent --bridge wss://ws.
|
|
1339
|
+
vibe-agent --bridge wss://ws.minivibeapp.com
|
|
1041
1340
|
|
|
1042
1341
|
# Start with custom host name
|
|
1043
|
-
vibe-agent --bridge wss://ws.
|
|
1342
|
+
vibe-agent --bridge wss://ws.minivibeapp.com --name "AWS Dev Server"
|
|
1044
1343
|
`);
|
|
1045
1344
|
}
|
|
1046
1345
|
|
|
@@ -1141,7 +1440,7 @@ async function main() {
|
|
|
1141
1440
|
|
|
1142
1441
|
// Validate requirements
|
|
1143
1442
|
if (!bridgeUrl) {
|
|
1144
|
-
log('No bridge URL. Run: vibe-agent --bridge wss://ws.
|
|
1443
|
+
log('No bridge URL. Run: vibe-agent --bridge wss://ws.minivibeapp.com', colors.red);
|
|
1145
1444
|
process.exit(1);
|
|
1146
1445
|
}
|
|
1147
1446
|
|