thevoidforge 21.0.0 → 21.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/wizard/danger-room.config.json +5 -0
- package/dist/wizard/ui/app.js +1231 -0
- package/dist/wizard/ui/danger-room-prophecy.js +217 -0
- package/dist/wizard/ui/danger-room.html +626 -0
- package/dist/wizard/ui/danger-room.js +880 -0
- package/dist/wizard/ui/deploy.html +177 -0
- package/dist/wizard/ui/deploy.js +582 -0
- package/dist/wizard/ui/favicon.svg +11 -0
- package/dist/wizard/ui/index.html +394 -0
- package/dist/wizard/ui/lobby.html +228 -0
- package/dist/wizard/ui/lobby.js +783 -0
- package/dist/wizard/ui/login.html +110 -0
- package/dist/wizard/ui/login.js +184 -0
- package/dist/wizard/ui/rollback.js +107 -0
- package/dist/wizard/ui/styles.css +1029 -0
- package/dist/wizard/ui/tower.html +171 -0
- package/dist/wizard/ui/tower.js +444 -0
- package/dist/wizard/ui/war-room-prophecy.js +217 -0
- package/dist/wizard/ui/war-room.html +219 -0
- package/dist/wizard/ui/war-room.js +285 -0
- package/package.json +2 -2
|
@@ -0,0 +1,171 @@
|
|
|
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">
|
|
6
|
+
<title>Avengers Tower — VoidForge</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
|
9
|
+
<style>
|
|
10
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #0d0d1a;
|
|
13
|
+
--surface: #1a1a2e;
|
|
14
|
+
--border: #2a2a3e;
|
|
15
|
+
--text: #e0e0e0;
|
|
16
|
+
--text-dim: #888;
|
|
17
|
+
--accent: #5b5bf7;
|
|
18
|
+
--accent-dim: rgba(91, 91, 247, 0.15);
|
|
19
|
+
--success: #22c55e;
|
|
20
|
+
--error: #ef4444;
|
|
21
|
+
--mono: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Menlo, Monaco, monospace;
|
|
22
|
+
}
|
|
23
|
+
body { background: var(--bg); color: var(--text); font-family: var(--mono); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
|
24
|
+
|
|
25
|
+
/* Header */
|
|
26
|
+
.tower-header {
|
|
27
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
28
|
+
padding: 8px 16px; background: var(--surface); border-bottom: 1px solid var(--border);
|
|
29
|
+
flex-shrink: 0;
|
|
30
|
+
}
|
|
31
|
+
.tower-header .logo { font-size: 14px; color: var(--accent); font-weight: 600; }
|
|
32
|
+
.tower-header .project-name {
|
|
33
|
+
font-size: 13px; color: var(--text-dim);
|
|
34
|
+
max-width: 40vw;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
text-overflow: ellipsis;
|
|
37
|
+
white-space: nowrap;
|
|
38
|
+
}
|
|
39
|
+
.tower-header .actions { display: flex; gap: 8px; }
|
|
40
|
+
|
|
41
|
+
/* Tab bar */
|
|
42
|
+
.tab-bar {
|
|
43
|
+
display: flex; align-items: center; background: var(--surface);
|
|
44
|
+
border-bottom: 1px solid var(--border); flex-shrink: 0; overflow-x: auto;
|
|
45
|
+
}
|
|
46
|
+
.tab {
|
|
47
|
+
padding: 6px 16px; font-size: 12px; color: var(--text-dim); cursor: pointer;
|
|
48
|
+
border-bottom: 2px solid transparent; white-space: nowrap;
|
|
49
|
+
display: flex; align-items: center; gap: 6px;
|
|
50
|
+
}
|
|
51
|
+
.tab:hover { color: var(--text); background: var(--accent-dim); }
|
|
52
|
+
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
53
|
+
.tab .close-tab {
|
|
54
|
+
font-size: 14px; cursor: pointer; color: var(--text-dim);
|
|
55
|
+
padding: 0 2px; border-radius: 3px;
|
|
56
|
+
}
|
|
57
|
+
.tab .close-tab:hover { color: var(--error); background: rgba(239, 68, 68, 0.15); }
|
|
58
|
+
.new-tab {
|
|
59
|
+
padding: 6px 12px; font-size: 14px; color: var(--text-dim);
|
|
60
|
+
cursor: pointer; border: none; background: none;
|
|
61
|
+
}
|
|
62
|
+
.new-tab:hover { color: var(--accent); }
|
|
63
|
+
|
|
64
|
+
/* Terminal container */
|
|
65
|
+
.terminal-container {
|
|
66
|
+
flex: 1; position: relative; overflow: hidden;
|
|
67
|
+
}
|
|
68
|
+
.terminal-panel {
|
|
69
|
+
position: absolute; inset: 0; display: none;
|
|
70
|
+
}
|
|
71
|
+
.terminal-panel.active { display: block; }
|
|
72
|
+
|
|
73
|
+
/* Button */
|
|
74
|
+
.btn {
|
|
75
|
+
padding: 4px 12px; border-radius: 4px; border: 1px solid var(--border);
|
|
76
|
+
background: var(--surface); color: var(--text); font-size: 12px;
|
|
77
|
+
font-family: var(--mono); cursor: pointer;
|
|
78
|
+
}
|
|
79
|
+
.btn:hover { border-color: var(--accent); }
|
|
80
|
+
.btn:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(91, 91, 247, 0.25); outline: none; }
|
|
81
|
+
|
|
82
|
+
/* Loading */
|
|
83
|
+
.loading {
|
|
84
|
+
display: flex; align-items: center; justify-content: center;
|
|
85
|
+
height: 100%; color: var(--text-dim); font-size: 14px;
|
|
86
|
+
animation: pulse 2s ease-in-out infinite;
|
|
87
|
+
}
|
|
88
|
+
@keyframes pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
|
|
89
|
+
|
|
90
|
+
/* Auto-command banner */
|
|
91
|
+
.auto-command-banner {
|
|
92
|
+
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
|
|
93
|
+
background: rgba(91, 91, 247, 0.08); border-bottom: 1px solid var(--border);
|
|
94
|
+
font-size: 13px; color: var(--text);
|
|
95
|
+
animation: slideDown 0.3s ease;
|
|
96
|
+
}
|
|
97
|
+
.auto-command-banner .btn { padding: 4px 12px; font-size: 12px; }
|
|
98
|
+
.auto-command-banner .btn-primary { box-shadow: 0 1px 4px rgba(91, 91, 247, 0.2); }
|
|
99
|
+
@keyframes slideDown { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
|
100
|
+
|
|
101
|
+
/* Vault unlock form */
|
|
102
|
+
.vault-unlock-form {
|
|
103
|
+
display: flex; align-items: center; justify-content: center;
|
|
104
|
+
height: 100%; padding: 24px;
|
|
105
|
+
}
|
|
106
|
+
.vault-unlock-card {
|
|
107
|
+
max-width: 400px; width: 100%; padding: 24px;
|
|
108
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
109
|
+
}
|
|
110
|
+
.vault-unlock-card h2 { font-size: 18px; font-weight: 600; margin-bottom: 8px; color: var(--accent); }
|
|
111
|
+
.vault-unlock-card p { font-size: 13px; color: var(--text-dim); }
|
|
112
|
+
|
|
113
|
+
/* Rollback panel */
|
|
114
|
+
.rollback-panel {
|
|
115
|
+
position: absolute; top: 0; right: 0; bottom: 0; width: 280px;
|
|
116
|
+
background: var(--surface); border-left: 1px solid var(--border);
|
|
117
|
+
transform: translateX(100%); transition: transform 0.2s ease;
|
|
118
|
+
z-index: 10; overflow-y: auto; font-size: 12px;
|
|
119
|
+
}
|
|
120
|
+
.rollback-panel.open { transform: translateX(0); }
|
|
121
|
+
.rollback-panel h3 { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--border); }
|
|
122
|
+
.deploy-entry { padding: 10px 16px; border-bottom: 1px solid var(--border); }
|
|
123
|
+
.deploy-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
|
124
|
+
.deploy-time { color: var(--text-dim); }
|
|
125
|
+
.deploy-url { color: var(--accent); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
126
|
+
|
|
127
|
+
/* Reduced motion */
|
|
128
|
+
@media (prefers-reduced-motion: reduce) {
|
|
129
|
+
*, *::before, *::after {
|
|
130
|
+
animation-duration: 0.01ms !important;
|
|
131
|
+
animation-iteration-count: 1 !important;
|
|
132
|
+
transition-duration: 0.01ms !important;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
</style>
|
|
136
|
+
</head>
|
|
137
|
+
<body>
|
|
138
|
+
<a href="#terminal-container" class="skip-nav">Skip to main content</a>
|
|
139
|
+
<noscript><div class="noscript-msg">VoidForge requires JavaScript to run.</div></noscript>
|
|
140
|
+
<header class="tower-header">
|
|
141
|
+
<div style="display: flex; align-items: center; gap: 12px;">
|
|
142
|
+
<a href="/lobby.html" class="btn" id="btn-back-lobby" title="Back to The Lobby" style="text-decoration: none; font-size: 12px; padding: 4px 10px;">← Lobby</a>
|
|
143
|
+
<span class="logo">Avengers Tower</span>
|
|
144
|
+
<span class="project-name" id="project-name"></span>
|
|
145
|
+
</div>
|
|
146
|
+
<div class="actions">
|
|
147
|
+
<button class="btn" id="btn-new-shell" title="New shell tab">+ Shell</button>
|
|
148
|
+
<button class="btn" id="btn-claude" title="Launch Claude Code">+ Claude Code</button>
|
|
149
|
+
<button class="btn" id="btn-toggle-rollback" title="Deploy history" aria-expanded="false" aria-controls="rollback-panel">Deploys</button>
|
|
150
|
+
</div>
|
|
151
|
+
</header>
|
|
152
|
+
|
|
153
|
+
<div class="tab-bar" id="tab-bar"></div>
|
|
154
|
+
|
|
155
|
+
<div id="tower-status" role="alert" aria-live="assertive" style="display:none; padding: 4px 16px; background: rgba(239,68,68,0.1); color: #ef4444; font-size: 12px; border-bottom: 1px solid var(--border);"></div>
|
|
156
|
+
|
|
157
|
+
<main class="terminal-container" id="terminal-container">
|
|
158
|
+
<div class="loading" id="loading-state">Assembling the Avengers...</div>
|
|
159
|
+
<aside class="rollback-panel" id="rollback-panel" aria-label="Deploy history">
|
|
160
|
+
<h3>Deploy History</h3>
|
|
161
|
+
<div id="deploy-list"></div>
|
|
162
|
+
<div id="rollback-status" role="status" aria-live="polite"></div>
|
|
163
|
+
</aside>
|
|
164
|
+
</main>
|
|
165
|
+
|
|
166
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
167
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
168
|
+
<script src="tower.js"></script>
|
|
169
|
+
<script src="rollback.js"></script>
|
|
170
|
+
</body>
|
|
171
|
+
</html>
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Avengers Tower — Browser terminal for VoidForge.
|
|
3
|
+
* xterm.js + WebSocket → server-side PTY (node-pty).
|
|
4
|
+
* Haku moves between worlds seamlessly.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
(function () {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
// ── State ──────────────────────────────────────────
|
|
11
|
+
const tabs = []; // { id, sessionId, label, terminal, ws, fitAddon, panelEl }
|
|
12
|
+
let activeTabId = null;
|
|
13
|
+
|
|
14
|
+
// Read project info from URL params
|
|
15
|
+
const params = new URLSearchParams(window.location.search);
|
|
16
|
+
const projectDir = params.get('dir') || '';
|
|
17
|
+
const projectName = params.get('name') || 'Project';
|
|
18
|
+
const autoCommand = params.get('auto') || ''; // 'campaign', 'build', or '' — auto-type after Claude launches
|
|
19
|
+
|
|
20
|
+
document.getElementById('project-name').textContent = '— ' + projectName;
|
|
21
|
+
|
|
22
|
+
// ── DOM refs ───────────────────────────────────────
|
|
23
|
+
const tabBar = document.getElementById('tab-bar');
|
|
24
|
+
const container = document.getElementById('terminal-container');
|
|
25
|
+
const loadingState = document.getElementById('loading-state');
|
|
26
|
+
|
|
27
|
+
tabBar.setAttribute('role', 'tablist');
|
|
28
|
+
|
|
29
|
+
const statusEl = document.getElementById('tower-status');
|
|
30
|
+
|
|
31
|
+
function showStatus(msg, durationMs) {
|
|
32
|
+
statusEl.textContent = msg;
|
|
33
|
+
statusEl.style.display = 'block';
|
|
34
|
+
if (durationMs) {
|
|
35
|
+
setTimeout(() => { statusEl.style.display = 'none'; }, durationMs);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── API helpers ────────────────────────────────────
|
|
40
|
+
async function createPtySession(label, initialCommand, cols, rows) {
|
|
41
|
+
const res = await fetch('/api/terminal/sessions', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
44
|
+
body: JSON.stringify({ projectDir, projectName, label, initialCommand, cols, rows }),
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const err = await res.json();
|
|
48
|
+
throw new Error(err.error || 'Failed to create session');
|
|
49
|
+
}
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
return { session: data.session, authToken: data.authToken };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function killPtySession(sessionId) {
|
|
55
|
+
await fetch('/api/terminal/kill', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
58
|
+
body: JSON.stringify({ sessionId }),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Tab management ─────────────────────────────────
|
|
63
|
+
let tabIdCounter = 0;
|
|
64
|
+
let hasRetried = false;
|
|
65
|
+
|
|
66
|
+
function createTab(label, sessionId, authToken) {
|
|
67
|
+
const tabId = ++tabIdCounter;
|
|
68
|
+
const tabCreatedAt = Date.now();
|
|
69
|
+
|
|
70
|
+
// Create terminal
|
|
71
|
+
const terminal = new Terminal({
|
|
72
|
+
theme: {
|
|
73
|
+
background: '#0d0d1a',
|
|
74
|
+
foreground: '#e0e0e0',
|
|
75
|
+
cursor: '#5b5bf7',
|
|
76
|
+
cursorAccent: '#0d0d1a',
|
|
77
|
+
selectionBackground: 'rgba(91, 91, 247, 0.3)',
|
|
78
|
+
black: '#1a1a2e',
|
|
79
|
+
red: '#ef4444',
|
|
80
|
+
green: '#22c55e',
|
|
81
|
+
yellow: '#eab308',
|
|
82
|
+
blue: '#5b5bf7',
|
|
83
|
+
magenta: '#a855f7',
|
|
84
|
+
cyan: '#06b6d4',
|
|
85
|
+
white: '#e0e0e0',
|
|
86
|
+
brightBlack: '#4a4a5e',
|
|
87
|
+
brightRed: '#f87171',
|
|
88
|
+
brightGreen: '#4ade80',
|
|
89
|
+
brightYellow: '#facc15',
|
|
90
|
+
brightBlue: '#818cf8',
|
|
91
|
+
brightMagenta: '#c084fc',
|
|
92
|
+
brightCyan: '#22d3ee',
|
|
93
|
+
brightWhite: '#ffffff',
|
|
94
|
+
},
|
|
95
|
+
fontFamily: "'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Menlo, Monaco, monospace",
|
|
96
|
+
fontSize: 13,
|
|
97
|
+
lineHeight: 1.2,
|
|
98
|
+
cursorBlink: true,
|
|
99
|
+
scrollback: 10000,
|
|
100
|
+
allowProposedApi: true,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const fitAddon = new FitAddon.FitAddon();
|
|
104
|
+
terminal.loadAddon(fitAddon);
|
|
105
|
+
|
|
106
|
+
// Create panel
|
|
107
|
+
const panelEl = document.createElement('div');
|
|
108
|
+
panelEl.className = 'terminal-panel';
|
|
109
|
+
panelEl.id = 'panel-' + tabId;
|
|
110
|
+
panelEl.setAttribute('role', 'tabpanel');
|
|
111
|
+
container.appendChild(panelEl);
|
|
112
|
+
|
|
113
|
+
terminal.open(panelEl);
|
|
114
|
+
fitAddon.fit();
|
|
115
|
+
|
|
116
|
+
// Connect WebSocket
|
|
117
|
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
118
|
+
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/terminal?session=${sessionId}&token=${encodeURIComponent(authToken)}`);
|
|
119
|
+
|
|
120
|
+
ws.onopen = () => {
|
|
121
|
+
// Send initial resize
|
|
122
|
+
const dims = fitAddon.proposeDimensions();
|
|
123
|
+
if (dims) {
|
|
124
|
+
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
ws.onmessage = (event) => {
|
|
129
|
+
terminal.write(event.data);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
ws.onclose = () => {
|
|
133
|
+
terminal.write('\r\n\x1b[90m[Session ended — close this tab or open a new one above]\x1b[0m\r\n');
|
|
134
|
+
// Auto-cleanup: if session ended within 2s of creation, it likely failed to start.
|
|
135
|
+
// Mark the tab for cleanup instead of leaving a dead session consuming MAX_SESSIONS.
|
|
136
|
+
const elapsed = Date.now() - tabCreatedAt;
|
|
137
|
+
if (elapsed < 2000) {
|
|
138
|
+
terminal.write('\x1b[33m[Session failed to start — cleaning up...]\x1b[0m\r\n');
|
|
139
|
+
// Remove the tab after a brief delay so the user can see the message
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
// Use tabId (not sessionId) — DOM elements are keyed by tabId
|
|
142
|
+
const deadTabEl = document.querySelector(`[data-tab-id="${tabId}"]`);
|
|
143
|
+
if (deadTabEl) deadTabEl.remove();
|
|
144
|
+
const deadPanelEl = document.getElementById(`panel-${tabId}`);
|
|
145
|
+
if (deadPanelEl) deadPanelEl.remove();
|
|
146
|
+
// Remove from tabs array using splice (tabs is const)
|
|
147
|
+
const idx = tabs.findIndex(t => t.id === tabId);
|
|
148
|
+
if (idx !== -1) tabs.splice(idx, 1);
|
|
149
|
+
terminal.dispose();
|
|
150
|
+
// If this was the auto-created first tab, retry once
|
|
151
|
+
if (tabs.length === 0 && !hasRetried) {
|
|
152
|
+
hasRetried = true;
|
|
153
|
+
init();
|
|
154
|
+
}
|
|
155
|
+
}, 1500);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Forward keystrokes to WebSocket
|
|
160
|
+
terminal.onData((data) => {
|
|
161
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
162
|
+
ws.send(data);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Handle resize
|
|
167
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
168
|
+
fitAddon.fit();
|
|
169
|
+
const dims = fitAddon.proposeDimensions();
|
|
170
|
+
if (dims && ws.readyState === WebSocket.OPEN) {
|
|
171
|
+
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }));
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
resizeObserver.observe(panelEl);
|
|
175
|
+
|
|
176
|
+
// Create tab element
|
|
177
|
+
const tabEl = document.createElement('div');
|
|
178
|
+
tabEl.className = 'tab';
|
|
179
|
+
tabEl.dataset.tabId = tabId;
|
|
180
|
+
tabEl.setAttribute('role', 'tab');
|
|
181
|
+
tabEl.setAttribute('aria-selected', 'false');
|
|
182
|
+
tabEl.setAttribute('tabindex', '0');
|
|
183
|
+
tabEl.innerHTML = `<span class="tab-label">${escapeHtml(label)}</span><button class="close-tab" role="button" aria-label="Close ${escapeHtml(label)} tab" title="Close">×</button>`;
|
|
184
|
+
|
|
185
|
+
tabEl.querySelector('.tab-label').addEventListener('click', () => {
|
|
186
|
+
switchTab(tabId);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
tabEl.querySelector('.close-tab').addEventListener('click', (e) => {
|
|
190
|
+
e.stopPropagation();
|
|
191
|
+
closeTab(tabId);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
tabBar.appendChild(tabEl);
|
|
195
|
+
|
|
196
|
+
const tab = { id: tabId, sessionId, label, terminal, ws, fitAddon, panelEl, tabEl, resizeObserver };
|
|
197
|
+
tabs.push(tab);
|
|
198
|
+
|
|
199
|
+
switchTab(tabId);
|
|
200
|
+
return tab;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function switchTab(tabId) {
|
|
204
|
+
activeTabId = tabId;
|
|
205
|
+
for (const tab of tabs) {
|
|
206
|
+
const isActive = tab.id === tabId;
|
|
207
|
+
tab.panelEl.classList.toggle('active', isActive);
|
|
208
|
+
tab.tabEl.classList.toggle('active', isActive);
|
|
209
|
+
tab.tabEl.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
|
210
|
+
if (isActive) {
|
|
211
|
+
tab.fitAddon.fit();
|
|
212
|
+
tab.terminal.focus();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function closeTab(tabId) {
|
|
218
|
+
const idx = tabs.findIndex((t) => t.id === tabId);
|
|
219
|
+
if (idx === -1) return;
|
|
220
|
+
|
|
221
|
+
const tab = tabs[idx];
|
|
222
|
+
tab.ws.close();
|
|
223
|
+
tab.terminal.dispose();
|
|
224
|
+
tab.resizeObserver.disconnect();
|
|
225
|
+
tab.panelEl.remove();
|
|
226
|
+
tab.tabEl.remove();
|
|
227
|
+
killPtySession(tab.sessionId);
|
|
228
|
+
tabs.splice(idx, 1);
|
|
229
|
+
|
|
230
|
+
// Switch to another tab if this was active
|
|
231
|
+
if (activeTabId === tabId && tabs.length > 0) {
|
|
232
|
+
switchTab(tabs[Math.max(0, idx - 1)].id);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Button handlers ────────────────────────────────
|
|
237
|
+
|
|
238
|
+
document.getElementById('btn-new-shell').addEventListener('click', async () => {
|
|
239
|
+
try {
|
|
240
|
+
const { session, authToken } = await createPtySession('Shell', undefined, 120, 30);
|
|
241
|
+
createTab('Shell', session.id, authToken);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
showStatus('Failed to create shell: ' + err.message, 5000);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
document.getElementById('btn-claude').addEventListener('click', async () => {
|
|
248
|
+
try {
|
|
249
|
+
const { session, authToken } = await createPtySession('Claude Code', 'claude --dangerously-skip-permissions', 120, 30);
|
|
250
|
+
createTab('Claude Code', session.id, authToken);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
showStatus('Failed to launch Claude Code: ' + err.message, 5000);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── Utilities ──────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function escapeHtml(str) {
|
|
259
|
+
const div = document.createElement('div');
|
|
260
|
+
div.appendChild(document.createTextNode(str));
|
|
261
|
+
return div.innerHTML;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Init ───────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
async function init() {
|
|
267
|
+
// CDN fallback — if xterm.js failed to load (offline, blocked, etc.), show a helpful message
|
|
268
|
+
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
|
|
269
|
+
container.innerHTML =
|
|
270
|
+
'<div style="padding: 24px; color: var(--text-dim); text-align: center;">' +
|
|
271
|
+
'<p>Terminal requires xterm.js which is loaded from CDN.</p>' +
|
|
272
|
+
'<p>Check your network connection or configure a local xterm.js installation.</p></div>';
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!projectDir) {
|
|
277
|
+
loadingState.textContent = 'No project directory specified. Launch Avengers Tower from Gandalf.';
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Check vault status BEFORE creating session — WebSocket upgrade requires vault password
|
|
282
|
+
try {
|
|
283
|
+
const statusRes = await fetch('/api/credentials/status');
|
|
284
|
+
const statusData = await statusRes.json();
|
|
285
|
+
if (!statusData.unlocked) {
|
|
286
|
+
showVaultUnlock();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
} catch { /* if status check fails, try anyway and let the error handler catch it */ }
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
loadingState.textContent = 'Launching Claude Code...';
|
|
293
|
+
const { session, authToken } = await createPtySession('Claude Code', 'claude --dangerously-skip-permissions', 120, 30);
|
|
294
|
+
loadingState.style.display = 'none';
|
|
295
|
+
createTab('Claude Code', session.id, authToken);
|
|
296
|
+
|
|
297
|
+
// Auto-send command after Claude Code boots (~3s delay)
|
|
298
|
+
if (autoCommand) {
|
|
299
|
+
const cmdLabel = autoCommand.startsWith('/') ? autoCommand : '/' + autoCommand;
|
|
300
|
+
const banner = document.createElement('div');
|
|
301
|
+
banner.className = 'auto-command-banner';
|
|
302
|
+
banner.setAttribute('role', 'status');
|
|
303
|
+
banner.innerHTML = `<span>Sending <strong>${escapeHtml(cmdLabel)}</strong> in <span id="auto-countdown">3</span>s...</span> <button class="btn btn-secondary" id="auto-cmd-cancel">Cancel</button>`;
|
|
304
|
+
document.querySelector('.tower-header').after(banner);
|
|
305
|
+
|
|
306
|
+
let cancelled = false;
|
|
307
|
+
let countdown = 3;
|
|
308
|
+
document.getElementById('auto-cmd-cancel').addEventListener('click', () => {
|
|
309
|
+
cancelled = true;
|
|
310
|
+
banner.remove();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const timer = setInterval(() => {
|
|
314
|
+
countdown--;
|
|
315
|
+
var el = document.getElementById('auto-countdown');
|
|
316
|
+
if (el) el.textContent = String(countdown);
|
|
317
|
+
if (countdown <= 0) {
|
|
318
|
+
clearInterval(timer);
|
|
319
|
+
if (!cancelled) {
|
|
320
|
+
const activeTab = tabs.find(t => t.id === activeTabId);
|
|
321
|
+
if (activeTab && activeTab.ws && activeTab.ws.readyState === WebSocket.OPEN) {
|
|
322
|
+
activeTab.ws.send(cmdLabel + '\r');
|
|
323
|
+
}
|
|
324
|
+
banner.remove();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}, 1000);
|
|
328
|
+
}
|
|
329
|
+
} catch (err) {
|
|
330
|
+
const msg = err.message || '';
|
|
331
|
+
if (msg.toLowerCase().includes('vault is locked') || msg.toLowerCase().includes('locked')) {
|
|
332
|
+
showVaultUnlock();
|
|
333
|
+
} else {
|
|
334
|
+
loadingState.textContent = 'Failed to start: ' + msg;
|
|
335
|
+
// Fallback — try a plain shell
|
|
336
|
+
try {
|
|
337
|
+
const { session, authToken } = await createPtySession('Shell', undefined, 120, 30);
|
|
338
|
+
loadingState.style.display = 'none';
|
|
339
|
+
createTab('Shell', session.id, authToken);
|
|
340
|
+
} catch (err2) {
|
|
341
|
+
const msg2 = err2.message || '';
|
|
342
|
+
if (msg2.toLowerCase().includes('vault is locked') || msg2.toLowerCase().includes('locked')) {
|
|
343
|
+
showVaultUnlock();
|
|
344
|
+
} else {
|
|
345
|
+
loadingState.textContent = 'Could not start terminal: ' + msg2;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function showVaultUnlock() {
|
|
353
|
+
loadingState.style.display = 'none';
|
|
354
|
+
// Remove any existing unlock form
|
|
355
|
+
const existing = document.getElementById('vault-unlock-form');
|
|
356
|
+
if (existing) existing.remove();
|
|
357
|
+
|
|
358
|
+
const form = document.createElement('div');
|
|
359
|
+
form.id = 'vault-unlock-form';
|
|
360
|
+
form.className = 'vault-unlock-form';
|
|
361
|
+
form.setAttribute('role', 'form');
|
|
362
|
+
form.setAttribute('aria-label', 'Unlock vault');
|
|
363
|
+
form.innerHTML = `
|
|
364
|
+
<div class="vault-unlock-card">
|
|
365
|
+
<h2>Vault Locked</h2>
|
|
366
|
+
<p>Enter your vault password to start a terminal session.</p>
|
|
367
|
+
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
|
368
|
+
<input type="password" id="vault-pwd" placeholder="Vault password" autocomplete="off"
|
|
369
|
+
style="flex: 1; padding: 8px 12px; background: var(--bg, #0a0a0a); border: 1px solid var(--border, #333); border-radius: 4px; color: var(--text, #e5e5e5); font-size: 14px;">
|
|
370
|
+
<button class="btn btn-primary" id="vault-unlock-btn" style="padding: 8px 16px;">Unlock</button>
|
|
371
|
+
</div>
|
|
372
|
+
<div id="vault-unlock-status" role="status" aria-live="polite" style="margin-top: 8px; font-size: 13px;"></div>
|
|
373
|
+
</div>
|
|
374
|
+
`;
|
|
375
|
+
container.parentElement.insertBefore(form, container);
|
|
376
|
+
|
|
377
|
+
const pwdInput = document.getElementById('vault-pwd');
|
|
378
|
+
const unlockBtn = document.getElementById('vault-unlock-btn');
|
|
379
|
+
const status = document.getElementById('vault-unlock-status');
|
|
380
|
+
pwdInput.focus();
|
|
381
|
+
|
|
382
|
+
async function doUnlock() {
|
|
383
|
+
const password = pwdInput.value;
|
|
384
|
+
if (!password) { status.textContent = 'Please enter your password.'; status.style.color = '#ef4444'; return; }
|
|
385
|
+
|
|
386
|
+
unlockBtn.disabled = true;
|
|
387
|
+
status.textContent = 'Unlocking...';
|
|
388
|
+
status.style.color = '#888';
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
const res = await fetch('/api/credentials/unlock', {
|
|
392
|
+
method: 'POST',
|
|
393
|
+
headers: { 'Content-Type': 'application/json', 'X-VoidForge-Request': '1' },
|
|
394
|
+
body: JSON.stringify({ password }),
|
|
395
|
+
});
|
|
396
|
+
const data = await res.json();
|
|
397
|
+
if (res.ok && data.unlocked) {
|
|
398
|
+
form.remove();
|
|
399
|
+
loadingState.style.display = '';
|
|
400
|
+
// Retry terminal creation now that vault is unlocked
|
|
401
|
+
init();
|
|
402
|
+
} else {
|
|
403
|
+
status.textContent = data.error || 'Wrong password.';
|
|
404
|
+
status.style.color = '#ef4444';
|
|
405
|
+
unlockBtn.disabled = false;
|
|
406
|
+
pwdInput.select();
|
|
407
|
+
}
|
|
408
|
+
} catch (e) {
|
|
409
|
+
status.textContent = 'Connection error.';
|
|
410
|
+
status.style.color = '#ef4444';
|
|
411
|
+
unlockBtn.disabled = false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
unlockBtn.addEventListener('click', doUnlock);
|
|
416
|
+
pwdInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doUnlock(); });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Back to Lobby — warn if terminals are open (sessions persist server-side)
|
|
420
|
+
const backBtn = document.getElementById('btn-back-lobby');
|
|
421
|
+
if (backBtn) {
|
|
422
|
+
backBtn.addEventListener('click', (e) => {
|
|
423
|
+
if (tabs.length > 0) {
|
|
424
|
+
const leave = confirm('Terminal sessions will continue running in the background. Leave?');
|
|
425
|
+
if (!leave) {
|
|
426
|
+
e.preventDefault();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function start() {
|
|
433
|
+
await init();
|
|
434
|
+
|
|
435
|
+
window.addEventListener('beforeunload', (e) => {
|
|
436
|
+
if (tabs.length > 0) {
|
|
437
|
+
e.preventDefault();
|
|
438
|
+
e.returnValue = '';
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
start();
|
|
444
|
+
})();
|