ninja-terminals 2.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/CLAUDE.md +121 -0
- package/ORCHESTRATOR-PROMPT.md +295 -0
- package/cli.js +117 -0
- package/lib/analyze-session.js +92 -0
- package/lib/evolution-writer.js +27 -0
- package/lib/permissions.js +311 -0
- package/lib/playbook-tracker.js +85 -0
- package/lib/resilience.js +458 -0
- package/lib/ring-buffer.js +125 -0
- package/lib/safe-file-writer.js +51 -0
- package/lib/scheduler.js +212 -0
- package/lib/settings-gen.js +159 -0
- package/lib/sse.js +103 -0
- package/lib/status-detect.js +229 -0
- package/lib/task-dag.js +547 -0
- package/lib/tool-rater.js +63 -0
- package/orchestrator/evolution-log.md +33 -0
- package/orchestrator/identity.md +60 -0
- package/orchestrator/metrics/.gitkeep +0 -0
- package/orchestrator/metrics/raw/.gitkeep +0 -0
- package/orchestrator/metrics/session-2026-03-23-setup.md +54 -0
- package/orchestrator/metrics/session-2026-03-24-appcast-build.md +55 -0
- package/orchestrator/playbooks.md +71 -0
- package/orchestrator/security-protocol.md +69 -0
- package/orchestrator/tool-registry.md +96 -0
- package/package.json +46 -0
- package/public/app.js +860 -0
- package/public/index.html +60 -0
- package/public/style.css +678 -0
- package/server.js +695 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
/* Ninja Terminals v2 — Frontend */
|
|
2
|
+
|
|
3
|
+
const WS_BASE = `ws://${location.host}`;
|
|
4
|
+
const API_BASE = '';
|
|
5
|
+
|
|
6
|
+
// ── State ────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const state = {
|
|
9
|
+
terminals: new Map(), // id -> { id, label, status, progress, elapsed, term, ws, fitAddon, paneEl, ... }
|
|
10
|
+
maximizedId: null,
|
|
11
|
+
activeId: null,
|
|
12
|
+
terminalIndex: new Map(), // id -> index (0-3) for feed coloring
|
|
13
|
+
nextIndex: 0,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// ── DOM Refs ─────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const grid = document.getElementById('grid');
|
|
19
|
+
const sidebar = document.getElementById('sidebar');
|
|
20
|
+
const activityFeed = document.getElementById('activity-feed');
|
|
21
|
+
const taskQueue = document.getElementById('task-queue');
|
|
22
|
+
const statusCounts = document.getElementById('status-counts');
|
|
23
|
+
const statusProgress = document.getElementById('status-progress');
|
|
24
|
+
const addTaskBtn = document.getElementById('add-task-btn');
|
|
25
|
+
const sidebarToggle = document.getElementById('sidebar-toggle');
|
|
26
|
+
|
|
27
|
+
// ── State Color Map ──────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const STATE_ICONS = {
|
|
30
|
+
idle: '\u25CB', // circle outline
|
|
31
|
+
working: '\u25B6', // play triangle
|
|
32
|
+
done: '\u2713', // checkmark
|
|
33
|
+
blocked: '\u23F8', // pause
|
|
34
|
+
error: '\u2717', // X
|
|
35
|
+
compacting: '\u21BB', // rotating arrows
|
|
36
|
+
waiting_approval: '\u26A0', // warning
|
|
37
|
+
starting: '\u25CB',
|
|
38
|
+
exited: '\u2717',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const STATE_LABELS = {
|
|
42
|
+
idle: 'IDLE',
|
|
43
|
+
working: 'WORKING',
|
|
44
|
+
done: 'DONE',
|
|
45
|
+
blocked: 'BLOCKED',
|
|
46
|
+
error: 'ERROR',
|
|
47
|
+
compacting: 'COMPACTING',
|
|
48
|
+
waiting_approval: 'APPROVAL',
|
|
49
|
+
starting: 'STARTING',
|
|
50
|
+
exited: 'EXITED',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ── Utilities ────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function timestamp() {
|
|
56
|
+
const d = new Date();
|
|
57
|
+
return d.toTimeString().slice(0, 8);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getTerminalFeedClass(id) {
|
|
61
|
+
let idx = state.terminalIndex.get(id);
|
|
62
|
+
if (idx === undefined) {
|
|
63
|
+
idx = state.nextIndex % 4;
|
|
64
|
+
state.terminalIndex.set(id, idx);
|
|
65
|
+
state.nextIndex++;
|
|
66
|
+
}
|
|
67
|
+
return `feed-t${idx + 1}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Activity Feed ────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function addFeedEntry(message, terminalId) {
|
|
73
|
+
const entry = document.createElement('div');
|
|
74
|
+
const feedClass = terminalId != null ? getTerminalFeedClass(terminalId) : '';
|
|
75
|
+
entry.className = `feed-entry ${feedClass}`;
|
|
76
|
+
entry.innerHTML = `<span class="feed-time">${timestamp()}</span><span class="feed-msg">${escapeHtml(message)}</span>`;
|
|
77
|
+
|
|
78
|
+
// Insert at top (most recent first)
|
|
79
|
+
if (activityFeed.firstChild) {
|
|
80
|
+
activityFeed.insertBefore(entry, activityFeed.firstChild);
|
|
81
|
+
} else {
|
|
82
|
+
activityFeed.appendChild(entry);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Cap at 200 entries
|
|
86
|
+
while (activityFeed.children.length > 200) {
|
|
87
|
+
activityFeed.removeChild(activityFeed.lastChild);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function escapeHtml(str) {
|
|
92
|
+
const div = document.createElement('div');
|
|
93
|
+
div.textContent = str;
|
|
94
|
+
return div.innerHTML;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Terminal Creation ────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function createTerminalUI(termData) {
|
|
100
|
+
const { id, label, status, elapsed, progress, taskName } = termData;
|
|
101
|
+
|
|
102
|
+
// Pane
|
|
103
|
+
const pane = document.createElement('div');
|
|
104
|
+
pane.className = 'terminal-pane';
|
|
105
|
+
pane.id = `pane-${id}`;
|
|
106
|
+
|
|
107
|
+
// Header
|
|
108
|
+
const header = document.createElement('div');
|
|
109
|
+
header.className = 'pane-header';
|
|
110
|
+
|
|
111
|
+
// Label (editable on double-click)
|
|
112
|
+
const labelEl = document.createElement('span');
|
|
113
|
+
labelEl.className = 'pane-label';
|
|
114
|
+
labelEl.textContent = label || `Terminal ${id.slice(0, 6)}`;
|
|
115
|
+
labelEl.title = 'Double-click to rename';
|
|
116
|
+
labelEl.addEventListener('dblclick', (e) => {
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
startLabelEdit(id, labelEl);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// State indicator
|
|
122
|
+
const stateEl = document.createElement('span');
|
|
123
|
+
stateEl.className = 'pane-state';
|
|
124
|
+
const stateIcon = document.createElement('span');
|
|
125
|
+
stateIcon.className = `state-icon ${status || 'idle'}`;
|
|
126
|
+
stateIcon.id = `state-icon-${id}`;
|
|
127
|
+
const stateText = document.createElement('span');
|
|
128
|
+
stateText.className = `state-text ${status || 'idle'}`;
|
|
129
|
+
stateText.id = `state-text-${id}`;
|
|
130
|
+
stateText.textContent = STATE_LABELS[status] || 'IDLE';
|
|
131
|
+
stateEl.appendChild(stateIcon);
|
|
132
|
+
stateEl.appendChild(stateText);
|
|
133
|
+
|
|
134
|
+
// Elapsed
|
|
135
|
+
const elapsedEl = document.createElement('span');
|
|
136
|
+
elapsedEl.className = 'pane-elapsed';
|
|
137
|
+
elapsedEl.id = `elapsed-${id}`;
|
|
138
|
+
elapsedEl.textContent = elapsed || '';
|
|
139
|
+
|
|
140
|
+
// Spacer
|
|
141
|
+
const spacer = document.createElement('span');
|
|
142
|
+
spacer.className = 'pane-spacer';
|
|
143
|
+
|
|
144
|
+
// Action buttons container
|
|
145
|
+
const actionsEl = document.createElement('span');
|
|
146
|
+
actionsEl.className = 'pane-actions';
|
|
147
|
+
actionsEl.id = `actions-${id}`;
|
|
148
|
+
|
|
149
|
+
// Progress bar
|
|
150
|
+
const progressTrack = document.createElement('div');
|
|
151
|
+
progressTrack.className = 'progress-bar-track';
|
|
152
|
+
const progressFill = document.createElement('div');
|
|
153
|
+
progressFill.className = `progress-bar-fill ${status || 'idle'}`;
|
|
154
|
+
progressFill.id = `progress-${id}`;
|
|
155
|
+
progressFill.style.width = `${progress || 0}%`;
|
|
156
|
+
progressTrack.appendChild(progressFill);
|
|
157
|
+
|
|
158
|
+
header.appendChild(labelEl);
|
|
159
|
+
header.appendChild(stateEl);
|
|
160
|
+
header.appendChild(elapsedEl);
|
|
161
|
+
header.appendChild(spacer);
|
|
162
|
+
header.appendChild(actionsEl);
|
|
163
|
+
header.appendChild(progressTrack);
|
|
164
|
+
|
|
165
|
+
// Double-click header to maximize/restore
|
|
166
|
+
header.addEventListener('dblclick', (e) => {
|
|
167
|
+
if (e.target.closest('.pane-label') || e.target.closest('.action-btn')) return;
|
|
168
|
+
toggleMaximize(id);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Terminal container
|
|
172
|
+
const container = document.createElement('div');
|
|
173
|
+
container.className = 'terminal-container';
|
|
174
|
+
container.id = `terminal-${id}`;
|
|
175
|
+
|
|
176
|
+
pane.appendChild(header);
|
|
177
|
+
pane.appendChild(container);
|
|
178
|
+
grid.appendChild(pane);
|
|
179
|
+
|
|
180
|
+
// Click pane to focus
|
|
181
|
+
pane.addEventListener('mousedown', () => {
|
|
182
|
+
setActiveTerminal(id);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// xterm.js
|
|
186
|
+
const term = new window.Terminal({
|
|
187
|
+
cursorBlink: true,
|
|
188
|
+
fontSize: 13,
|
|
189
|
+
fontFamily: "'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace",
|
|
190
|
+
theme: {
|
|
191
|
+
background: '#0C0C0C',
|
|
192
|
+
foreground: '#C8C0B4',
|
|
193
|
+
cursor: '#E8A917',
|
|
194
|
+
cursorAccent: '#0C0C0C',
|
|
195
|
+
selectionBackground: '#2a2520',
|
|
196
|
+
},
|
|
197
|
+
scrollback: 5000,
|
|
198
|
+
allowProposedApi: true,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const fitAddon = new window.FitAddon.FitAddon();
|
|
202
|
+
const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
|
|
203
|
+
term.loadAddon(fitAddon);
|
|
204
|
+
term.loadAddon(webLinksAddon);
|
|
205
|
+
term.open(container);
|
|
206
|
+
|
|
207
|
+
requestAnimationFrame(() => {
|
|
208
|
+
try { fitAddon.fit(); } catch {}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// WebSocket
|
|
212
|
+
const ws = new WebSocket(`${WS_BASE}/ws/${id}`);
|
|
213
|
+
ws.binaryType = 'arraybuffer';
|
|
214
|
+
|
|
215
|
+
ws.onopen = () => {
|
|
216
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
ws.onmessage = (event) => {
|
|
220
|
+
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data);
|
|
221
|
+
term.write(data);
|
|
222
|
+
// Always scroll to bottom on new output
|
|
223
|
+
term.scrollToBottom();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
ws.onclose = () => {
|
|
227
|
+
term.write('\r\n\x1b[31m[Connection closed]\x1b[0m\r\n');
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
term.onData((data) => {
|
|
231
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
term.onResize(({ cols, rows }) => {
|
|
235
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
236
|
+
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Store
|
|
241
|
+
const termState = {
|
|
242
|
+
id,
|
|
243
|
+
label: label || `Terminal ${id.slice(0, 6)}`,
|
|
244
|
+
status: status || 'idle',
|
|
245
|
+
progress: progress || 0,
|
|
246
|
+
elapsed: elapsed || '',
|
|
247
|
+
taskName: taskName || '',
|
|
248
|
+
term,
|
|
249
|
+
ws,
|
|
250
|
+
fitAddon,
|
|
251
|
+
paneEl: pane,
|
|
252
|
+
labelEl,
|
|
253
|
+
};
|
|
254
|
+
state.terminals.set(id, termState);
|
|
255
|
+
|
|
256
|
+
// Assign feed index
|
|
257
|
+
getTerminalFeedClass(id);
|
|
258
|
+
|
|
259
|
+
// Set initial action buttons
|
|
260
|
+
updateActionButtons(id, termState.status);
|
|
261
|
+
|
|
262
|
+
// Set as active
|
|
263
|
+
setActiveTerminal(id);
|
|
264
|
+
|
|
265
|
+
addFeedEntry(`Terminal created: ${termState.label}`, id);
|
|
266
|
+
|
|
267
|
+
return termState;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Label Editing ────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
function startLabelEdit(id, labelEl) {
|
|
273
|
+
const t = state.terminals.get(id);
|
|
274
|
+
if (!t) return;
|
|
275
|
+
|
|
276
|
+
const input = document.createElement('input');
|
|
277
|
+
input.type = 'text';
|
|
278
|
+
input.className = 'pane-label editing';
|
|
279
|
+
input.value = t.label;
|
|
280
|
+
input.style.width = `${Math.max(80, labelEl.offsetWidth + 20)}px`;
|
|
281
|
+
|
|
282
|
+
labelEl.replaceWith(input);
|
|
283
|
+
input.focus();
|
|
284
|
+
input.select();
|
|
285
|
+
|
|
286
|
+
const commit = () => {
|
|
287
|
+
const newLabel = input.value.trim() || t.label;
|
|
288
|
+
t.label = newLabel;
|
|
289
|
+
|
|
290
|
+
const newLabelEl = document.createElement('span');
|
|
291
|
+
newLabelEl.className = 'pane-label';
|
|
292
|
+
newLabelEl.textContent = newLabel;
|
|
293
|
+
newLabelEl.title = 'Double-click to rename';
|
|
294
|
+
newLabelEl.addEventListener('dblclick', (e) => {
|
|
295
|
+
e.stopPropagation();
|
|
296
|
+
startLabelEdit(id, newLabelEl);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
input.replaceWith(newLabelEl);
|
|
300
|
+
t.labelEl = newLabelEl;
|
|
301
|
+
|
|
302
|
+
// Persist to server
|
|
303
|
+
fetch(`${API_BASE}/api/terminals/${id}/label`, {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
headers: { 'Content-Type': 'application/json' },
|
|
306
|
+
body: JSON.stringify({ label: newLabel }),
|
|
307
|
+
}).catch(() => {});
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
input.addEventListener('blur', commit);
|
|
311
|
+
input.addEventListener('keydown', (e) => {
|
|
312
|
+
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
|
|
313
|
+
if (e.key === 'Escape') { input.value = t.label; input.blur(); }
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Action Buttons ───────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
function updateActionButtons(id, status) {
|
|
320
|
+
const actionsEl = document.getElementById(`actions-${id}`);
|
|
321
|
+
if (!actionsEl) return;
|
|
322
|
+
actionsEl.innerHTML = '';
|
|
323
|
+
|
|
324
|
+
const buttons = [];
|
|
325
|
+
|
|
326
|
+
switch (status) {
|
|
327
|
+
case 'working':
|
|
328
|
+
case 'starting':
|
|
329
|
+
buttons.push({ label: 'Pause', action: () => pauseTerminal(id) });
|
|
330
|
+
buttons.push({ label: 'Kill', action: () => killTerminal(id), danger: true });
|
|
331
|
+
break;
|
|
332
|
+
case 'done':
|
|
333
|
+
buttons.push({ label: 'Clear', action: () => clearTerminal(id) });
|
|
334
|
+
buttons.push({ label: 'New', action: () => restartTerminal(id) });
|
|
335
|
+
break;
|
|
336
|
+
case 'blocked':
|
|
337
|
+
case 'waiting_approval':
|
|
338
|
+
buttons.push({ label: 'Unblock', action: () => restartTerminal(id) });
|
|
339
|
+
buttons.push({ label: 'Kill', action: () => killTerminal(id), danger: true });
|
|
340
|
+
break;
|
|
341
|
+
case 'error':
|
|
342
|
+
case 'exited':
|
|
343
|
+
buttons.push({ label: 'Retry', action: () => restartTerminal(id) });
|
|
344
|
+
buttons.push({ label: 'Kill', action: () => killTerminal(id), danger: true });
|
|
345
|
+
break;
|
|
346
|
+
case 'compacting':
|
|
347
|
+
buttons.push({ label: 'Kill', action: () => killTerminal(id), danger: true });
|
|
348
|
+
break;
|
|
349
|
+
case 'idle':
|
|
350
|
+
default:
|
|
351
|
+
buttons.push({ label: 'Kill', action: () => killTerminal(id), danger: true });
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const btn of buttons) {
|
|
356
|
+
const el = document.createElement('button');
|
|
357
|
+
el.className = `action-btn${btn.danger ? ' danger' : ''}`;
|
|
358
|
+
el.textContent = btn.label;
|
|
359
|
+
// mousedown + preventDefault to avoid stealing focus from terminal
|
|
360
|
+
el.addEventListener('mousedown', (e) => {
|
|
361
|
+
e.preventDefault();
|
|
362
|
+
e.stopPropagation();
|
|
363
|
+
btn.action();
|
|
364
|
+
});
|
|
365
|
+
actionsEl.appendChild(el);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Terminal Actions ─────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
function setActiveTerminal(id) {
|
|
372
|
+
state.activeId = id;
|
|
373
|
+
for (const [tid, terminal] of state.terminals) {
|
|
374
|
+
terminal.paneEl.classList.toggle('active', tid === id);
|
|
375
|
+
}
|
|
376
|
+
const t = state.terminals.get(id);
|
|
377
|
+
if (t) t.term.focus();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function toggleMaximize(id) {
|
|
381
|
+
const t = state.terminals.get(id);
|
|
382
|
+
if (!t) return;
|
|
383
|
+
|
|
384
|
+
if (state.maximizedId === id) {
|
|
385
|
+
// Restore
|
|
386
|
+
t.paneEl.classList.remove('maximized');
|
|
387
|
+
state.maximizedId = null;
|
|
388
|
+
for (const [, terminal] of state.terminals) {
|
|
389
|
+
terminal.paneEl.classList.remove('hidden');
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
// Maximize
|
|
393
|
+
if (state.maximizedId) {
|
|
394
|
+
const prev = state.terminals.get(state.maximizedId);
|
|
395
|
+
if (prev) prev.paneEl.classList.remove('maximized');
|
|
396
|
+
}
|
|
397
|
+
for (const [tid, terminal] of state.terminals) {
|
|
398
|
+
if (tid !== id) terminal.paneEl.classList.add('hidden');
|
|
399
|
+
else terminal.paneEl.classList.remove('hidden');
|
|
400
|
+
}
|
|
401
|
+
t.paneEl.classList.add('maximized');
|
|
402
|
+
state.maximizedId = id;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
requestAnimationFrame(() => fitAll());
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function closeTerminal(id) {
|
|
409
|
+
const t = state.terminals.get(id);
|
|
410
|
+
if (!t) return;
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
await fetch(`${API_BASE}/api/terminals/${id}`, { method: 'DELETE' });
|
|
414
|
+
} catch {}
|
|
415
|
+
|
|
416
|
+
t.ws.close();
|
|
417
|
+
t.term.dispose();
|
|
418
|
+
t.paneEl.remove();
|
|
419
|
+
state.terminals.delete(id);
|
|
420
|
+
|
|
421
|
+
if (state.maximizedId === id) {
|
|
422
|
+
state.maximizedId = null;
|
|
423
|
+
for (const [, terminal] of state.terminals) {
|
|
424
|
+
terminal.paneEl.classList.remove('hidden');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
addFeedEntry(`Terminal closed: ${t.label}`, id);
|
|
429
|
+
updateStatusBar();
|
|
430
|
+
requestAnimationFrame(() => fitAll());
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function killTerminal(id) {
|
|
434
|
+
try {
|
|
435
|
+
await fetch(`${API_BASE}/api/terminals/${id}/kill`, { method: 'POST' });
|
|
436
|
+
addFeedEntry(`Kill sent to terminal`, id);
|
|
437
|
+
} catch (err) {
|
|
438
|
+
console.error('Kill failed:', err);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function pauseTerminal(id) {
|
|
443
|
+
// Send escape sequence (Ctrl+C)
|
|
444
|
+
try {
|
|
445
|
+
await fetch(`${API_BASE}/api/terminals/${id}/input`, {
|
|
446
|
+
method: 'POST',
|
|
447
|
+
headers: { 'Content-Type': 'application/json' },
|
|
448
|
+
body: JSON.stringify({ text: '\x1b' }),
|
|
449
|
+
});
|
|
450
|
+
addFeedEntry(`Escape sent to terminal`, id);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
console.error('Pause failed:', err);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function restartTerminal(id) {
|
|
457
|
+
try {
|
|
458
|
+
await fetch(`${API_BASE}/api/terminals/${id}/restart`, { method: 'POST' });
|
|
459
|
+
addFeedEntry(`Restart requested`, id);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
console.error('Restart failed:', err);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function clearTerminal(id) {
|
|
466
|
+
const t = state.terminals.get(id);
|
|
467
|
+
if (t) {
|
|
468
|
+
t.term.clear();
|
|
469
|
+
addFeedEntry(`Terminal cleared`, id);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function addNewTerminal() {
|
|
474
|
+
try {
|
|
475
|
+
const res = await fetch(`${API_BASE}/api/terminals`, {
|
|
476
|
+
method: 'POST',
|
|
477
|
+
headers: { 'Content-Type': 'application/json' },
|
|
478
|
+
});
|
|
479
|
+
const data = await res.json();
|
|
480
|
+
createTerminalUI(data);
|
|
481
|
+
requestAnimationFrame(() => fitAll());
|
|
482
|
+
} catch (err) {
|
|
483
|
+
console.error('Failed to create terminal:', err);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── Fit All Terminals ────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
function fitAll() {
|
|
490
|
+
for (const [, t] of state.terminals) {
|
|
491
|
+
if (!t.paneEl.classList.contains('hidden')) {
|
|
492
|
+
try { t.fitAddon.fit(); } catch {}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ── Status Updates ───────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
function updateTerminalState(id, newStatus, extra) {
|
|
500
|
+
const t = state.terminals.get(id);
|
|
501
|
+
if (!t) return;
|
|
502
|
+
|
|
503
|
+
const oldStatus = t.status;
|
|
504
|
+
t.status = newStatus;
|
|
505
|
+
|
|
506
|
+
if (extra) {
|
|
507
|
+
if (extra.elapsed !== undefined) t.elapsed = extra.elapsed;
|
|
508
|
+
if (extra.progress !== undefined) t.progress = extra.progress;
|
|
509
|
+
if (extra.label !== undefined) {
|
|
510
|
+
t.label = extra.label;
|
|
511
|
+
if (t.labelEl) t.labelEl.textContent = extra.label;
|
|
512
|
+
}
|
|
513
|
+
if (extra.taskName !== undefined) t.taskName = extra.taskName;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Update state icon
|
|
517
|
+
const stateIcon = document.getElementById(`state-icon-${id}`);
|
|
518
|
+
if (stateIcon) stateIcon.className = `state-icon ${newStatus}`;
|
|
519
|
+
|
|
520
|
+
// Update state text
|
|
521
|
+
const stateText = document.getElementById(`state-text-${id}`);
|
|
522
|
+
if (stateText) {
|
|
523
|
+
stateText.className = `state-text ${newStatus}`;
|
|
524
|
+
stateText.textContent = STATE_LABELS[newStatus] || newStatus.toUpperCase();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Update elapsed
|
|
528
|
+
const elapsedEl = document.getElementById(`elapsed-${id}`);
|
|
529
|
+
if (elapsedEl) elapsedEl.textContent = t.elapsed;
|
|
530
|
+
|
|
531
|
+
// Update progress bar
|
|
532
|
+
const progressFill = document.getElementById(`progress-${id}`);
|
|
533
|
+
if (progressFill) {
|
|
534
|
+
progressFill.className = `progress-bar-fill ${newStatus}`;
|
|
535
|
+
progressFill.style.width = `${t.progress}%`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Update pane border state classes
|
|
539
|
+
t.paneEl.classList.remove('state-error', 'state-blocked', 'state-done', 'flash');
|
|
540
|
+
|
|
541
|
+
if (newStatus === 'error' || newStatus === 'exited') {
|
|
542
|
+
t.paneEl.classList.add('state-error');
|
|
543
|
+
} else if (newStatus === 'blocked' || newStatus === 'waiting_approval') {
|
|
544
|
+
t.paneEl.classList.add('state-blocked');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Done flash animation (one-shot)
|
|
548
|
+
if (newStatus === 'done' && oldStatus !== 'done') {
|
|
549
|
+
t.paneEl.classList.add('state-done', 'flash');
|
|
550
|
+
t.paneEl.addEventListener('animationend', () => {
|
|
551
|
+
t.paneEl.classList.remove('flash');
|
|
552
|
+
}, { once: true });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Update action buttons
|
|
556
|
+
updateActionButtons(id, newStatus);
|
|
557
|
+
|
|
558
|
+
// Update status bar
|
|
559
|
+
updateStatusBar();
|
|
560
|
+
|
|
561
|
+
// Desktop notification for done/error when tab not focused
|
|
562
|
+
if ((newStatus === 'done' || newStatus === 'error') && oldStatus !== newStatus && !document.hasFocus()) {
|
|
563
|
+
fireNotification(t.label, newStatus);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function updateProgress(id, progress) {
|
|
568
|
+
const t = state.terminals.get(id);
|
|
569
|
+
if (!t) return;
|
|
570
|
+
|
|
571
|
+
t.progress = progress;
|
|
572
|
+
|
|
573
|
+
const progressFill = document.getElementById(`progress-${id}`);
|
|
574
|
+
if (progressFill) {
|
|
575
|
+
progressFill.style.width = `${progress}%`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
updateStatusBar();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function updateStatusBar() {
|
|
582
|
+
const counts = {};
|
|
583
|
+
let totalProgress = 0;
|
|
584
|
+
let termCount = 0;
|
|
585
|
+
|
|
586
|
+
for (const [, t] of state.terminals) {
|
|
587
|
+
const s = t.status || 'idle';
|
|
588
|
+
counts[s] = (counts[s] || 0) + 1;
|
|
589
|
+
totalProgress += t.progress || 0;
|
|
590
|
+
termCount++;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Render counts
|
|
594
|
+
const order = ['working', 'done', 'blocked', 'error', 'waiting_approval', 'compacting', 'idle'];
|
|
595
|
+
const parts = [];
|
|
596
|
+
for (const s of order) {
|
|
597
|
+
if (counts[s]) {
|
|
598
|
+
parts.push(`<span class="status-count"><span class="status-dot ${s}"></span>${counts[s]} ${STATE_LABELS[s] ? STATE_LABELS[s].toLowerCase() : s}</span>`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
statusCounts.innerHTML = parts.join('');
|
|
602
|
+
|
|
603
|
+
// Overall progress
|
|
604
|
+
const overall = termCount > 0 ? Math.round(totalProgress / termCount) : 0;
|
|
605
|
+
statusProgress.textContent = `Overall: ${overall}%`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── Desktop Notifications ────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
function requestNotificationPermission() {
|
|
611
|
+
if ('Notification' in window && Notification.permission === 'default') {
|
|
612
|
+
Notification.requestPermission();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function fireNotification(label, status) {
|
|
617
|
+
if ('Notification' in window && Notification.permission === 'granted') {
|
|
618
|
+
const icon = status === 'done' ? '\u2713' : '\u2717';
|
|
619
|
+
new Notification(`Ninja Terminals: ${label}`, {
|
|
620
|
+
body: `${icon} Terminal is ${status.toUpperCase()}`,
|
|
621
|
+
tag: `ninja-${label}-${status}`,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ── SSE Connection ───────────────────────────────────────────
|
|
627
|
+
|
|
628
|
+
function connectSSE() {
|
|
629
|
+
const evtSource = new EventSource(`${API_BASE}/api/events`);
|
|
630
|
+
|
|
631
|
+
evtSource.addEventListener('status_change', (e) => {
|
|
632
|
+
try {
|
|
633
|
+
const data = JSON.parse(e.data);
|
|
634
|
+
const { terminalId, status, elapsed, progress, label, taskName } = data;
|
|
635
|
+
updateTerminalState(terminalId, status, { elapsed, progress, label, taskName });
|
|
636
|
+
|
|
637
|
+
const t = state.terminals.get(terminalId);
|
|
638
|
+
const name = t ? t.label : terminalId;
|
|
639
|
+
addFeedEntry(`${name} -> ${STATE_LABELS[status] || status}`, terminalId);
|
|
640
|
+
} catch {}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
evtSource.addEventListener('progress', (e) => {
|
|
644
|
+
try {
|
|
645
|
+
const data = JSON.parse(e.data);
|
|
646
|
+
const { terminalId, progress } = data;
|
|
647
|
+
updateProgress(terminalId, progress);
|
|
648
|
+
} catch {}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
evtSource.addEventListener('permission_request', (e) => {
|
|
652
|
+
try {
|
|
653
|
+
const data = JSON.parse(e.data);
|
|
654
|
+
const { terminalId, message } = data;
|
|
655
|
+
const t = state.terminals.get(terminalId);
|
|
656
|
+
const name = t ? t.label : terminalId;
|
|
657
|
+
addFeedEntry(`[APPROVAL] ${name}: ${message || 'Permission requested'}`, terminalId);
|
|
658
|
+
} catch {}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
evtSource.addEventListener('error', (e) => {
|
|
662
|
+
try {
|
|
663
|
+
const data = JSON.parse(e.data);
|
|
664
|
+
const { terminalId, message } = data;
|
|
665
|
+
const t = state.terminals.get(terminalId);
|
|
666
|
+
const name = t ? t.label : terminalId;
|
|
667
|
+
addFeedEntry(`[ERROR] ${name}: ${message || 'Unknown error'}`, terminalId);
|
|
668
|
+
} catch {}
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
evtSource.addEventListener('compacted', (e) => {
|
|
672
|
+
try {
|
|
673
|
+
const data = JSON.parse(e.data);
|
|
674
|
+
const { terminalId } = data;
|
|
675
|
+
const t = state.terminals.get(terminalId);
|
|
676
|
+
const name = t ? t.label : terminalId;
|
|
677
|
+
addFeedEntry(`${name}: Context compacted`, terminalId);
|
|
678
|
+
} catch {}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
evtSource.addEventListener('context_low', (e) => {
|
|
682
|
+
try {
|
|
683
|
+
const data = JSON.parse(e.data);
|
|
684
|
+
const { terminalId, contextPct } = data;
|
|
685
|
+
const t = state.terminals.get(terminalId);
|
|
686
|
+
const name = t ? t.label : terminalId;
|
|
687
|
+
addFeedEntry(`[WARN] ${name}: Context low (${contextPct}%)`, terminalId);
|
|
688
|
+
} catch {}
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
evtSource.onerror = () => {
|
|
692
|
+
// EventSource will auto-reconnect
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
return evtSource;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── Task Queue ───────────────────────────────────────────────
|
|
699
|
+
|
|
700
|
+
async function fetchTasks() {
|
|
701
|
+
try {
|
|
702
|
+
const res = await fetch(`${API_BASE}/api/tasks`);
|
|
703
|
+
if (!res.ok) {
|
|
704
|
+
taskQueue.innerHTML = '<div class="no-tasks">No tasks</div>';
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const tasks = await res.json();
|
|
708
|
+
renderTasks(tasks);
|
|
709
|
+
} catch {
|
|
710
|
+
taskQueue.innerHTML = '<div class="no-tasks">No tasks</div>';
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function renderTasks(tasks) {
|
|
715
|
+
if (!tasks || !Array.isArray(tasks) || tasks.length === 0) {
|
|
716
|
+
taskQueue.innerHTML = '<div class="no-tasks">No tasks</div>';
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
taskQueue.innerHTML = '';
|
|
721
|
+
for (const task of tasks) {
|
|
722
|
+
const item = document.createElement('div');
|
|
723
|
+
item.className = 'task-item';
|
|
724
|
+
const dotClass = task.status || 'pending';
|
|
725
|
+
item.innerHTML = `
|
|
726
|
+
<span class="task-dot ${dotClass}"></span>
|
|
727
|
+
<span class="task-name" title="${escapeHtml(task.name || task.id || '')}">${escapeHtml(task.name || task.id || 'Unnamed')}</span>
|
|
728
|
+
<span class="task-status">${escapeHtml(task.status || 'pending')}</span>
|
|
729
|
+
`;
|
|
730
|
+
taskQueue.appendChild(item);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ── Status Polling (fallback + elapsed updates) ──────────────
|
|
735
|
+
|
|
736
|
+
async function pollStatus() {
|
|
737
|
+
try {
|
|
738
|
+
const res = await fetch(`${API_BASE}/api/terminals`);
|
|
739
|
+
const list = await res.json();
|
|
740
|
+
|
|
741
|
+
for (const item of list) {
|
|
742
|
+
const t = state.terminals.get(item.id);
|
|
743
|
+
if (!t) {
|
|
744
|
+
// Terminal exists on server but not in UI — create it
|
|
745
|
+
createTerminalUI(item);
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Update elapsed time (SSE might not send this continuously)
|
|
750
|
+
if (item.elapsed !== undefined) {
|
|
751
|
+
t.elapsed = item.elapsed;
|
|
752
|
+
const elapsedEl = document.getElementById(`elapsed-${item.id}`);
|
|
753
|
+
if (elapsedEl) elapsedEl.textContent = item.elapsed;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Update progress if present
|
|
757
|
+
if (item.progress !== undefined && item.progress !== t.progress) {
|
|
758
|
+
updateProgress(item.id, item.progress);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Update status if changed (SSE is primary, this is fallback)
|
|
762
|
+
if (item.status && item.status !== t.status) {
|
|
763
|
+
updateTerminalState(item.id, item.status, {
|
|
764
|
+
elapsed: item.elapsed,
|
|
765
|
+
progress: item.progress,
|
|
766
|
+
label: item.label,
|
|
767
|
+
taskName: item.taskName,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Check for removed terminals
|
|
773
|
+
for (const [id] of state.terminals) {
|
|
774
|
+
if (!list.find((item) => item.id === id)) {
|
|
775
|
+
closeTerminal(id);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
updateStatusBar();
|
|
780
|
+
} catch {}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ── Sidebar Toggle ───────────────────────────────────────────
|
|
784
|
+
|
|
785
|
+
function setupSidebar() {
|
|
786
|
+
sidebarToggle.addEventListener('click', () => {
|
|
787
|
+
sidebar.classList.toggle('collapsed');
|
|
788
|
+
requestAnimationFrame(() => fitAll());
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// Mobile: create overlay toggle
|
|
792
|
+
const mobileToggle = document.createElement('button');
|
|
793
|
+
mobileToggle.className = 'mobile-toggle';
|
|
794
|
+
mobileToggle.innerHTML = '☰';
|
|
795
|
+
mobileToggle.addEventListener('click', () => {
|
|
796
|
+
sidebar.classList.toggle('open');
|
|
797
|
+
sidebar.classList.remove('collapsed');
|
|
798
|
+
});
|
|
799
|
+
document.body.appendChild(mobileToggle);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// ── Add Task ─────────────────────────────────────────────────
|
|
803
|
+
|
|
804
|
+
function setupAddTask() {
|
|
805
|
+
addTaskBtn.addEventListener('click', () => {
|
|
806
|
+
addNewTerminal();
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ── Resize Handler ───────────────────────────────────────────
|
|
811
|
+
|
|
812
|
+
let resizeTimeout;
|
|
813
|
+
window.addEventListener('resize', () => {
|
|
814
|
+
clearTimeout(resizeTimeout);
|
|
815
|
+
resizeTimeout = setTimeout(() => fitAll(), 100);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// ── Initialize ───────────────────────────────────────────────
|
|
819
|
+
|
|
820
|
+
async function init() {
|
|
821
|
+
// Request desktop notification permission
|
|
822
|
+
requestNotificationPermission();
|
|
823
|
+
|
|
824
|
+
// Setup sidebar
|
|
825
|
+
setupSidebar();
|
|
826
|
+
setupAddTask();
|
|
827
|
+
|
|
828
|
+
// Load existing terminals
|
|
829
|
+
try {
|
|
830
|
+
const res = await fetch(`${API_BASE}/api/terminals`);
|
|
831
|
+
const list = await res.json();
|
|
832
|
+
for (const item of list) {
|
|
833
|
+
createTerminalUI(item);
|
|
834
|
+
}
|
|
835
|
+
} catch (err) {
|
|
836
|
+
console.error('Failed to load terminals:', err);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Initial fit after all terminals loaded
|
|
840
|
+
setTimeout(() => fitAll(), 300);
|
|
841
|
+
|
|
842
|
+
// Connect SSE for real-time updates
|
|
843
|
+
connectSSE();
|
|
844
|
+
|
|
845
|
+
// Fetch initial task queue
|
|
846
|
+
fetchTasks();
|
|
847
|
+
|
|
848
|
+
// Poll status every 3 seconds (fallback for SSE + elapsed updates)
|
|
849
|
+
setInterval(pollStatus, 3000);
|
|
850
|
+
|
|
851
|
+
// Poll task queue every 5 seconds
|
|
852
|
+
setInterval(fetchTasks, 5000);
|
|
853
|
+
|
|
854
|
+
// Initial status bar
|
|
855
|
+
updateStatusBar();
|
|
856
|
+
|
|
857
|
+
addFeedEntry('Ninja Terminals v2 started');
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
init();
|