ninja-terminals 2.3.9 → 2.4.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/CLAUDE.md +2 -22
- package/README.md +3 -1
- package/cli.js +34 -52
- package/lib/ninja-request.js +247 -0
- package/lib/settings-gen.js +0 -13
- package/mcp-server.js +7 -33
- package/ninja-ensure.js +92 -16
- package/package.json +1 -1
- package/public/app.js +55 -308
- package/public/index.html +15 -44
- package/public/style.css +78 -6
- package/server.js +15 -6
package/ninja-ensure.js
CHANGED
|
@@ -13,6 +13,10 @@ const {
|
|
|
13
13
|
writeRuntimeSession,
|
|
14
14
|
requestJson,
|
|
15
15
|
} = require('./lib/runtime-session');
|
|
16
|
+
const {
|
|
17
|
+
readNinjaRequest,
|
|
18
|
+
inferNinjaLaunchConfig,
|
|
19
|
+
} = require('./lib/ninja-request');
|
|
16
20
|
|
|
17
21
|
const LOG_FILE = path.join(SESSION_DIR, 'ninja-server.log');
|
|
18
22
|
const PROJECT_ROOT = __dirname;
|
|
@@ -20,16 +24,21 @@ const PROJECT_ROOT = __dirname;
|
|
|
20
24
|
const PROBE_PORTS = [3300, 3301, 3302, 3303, 3304, 3305, 3306, 3307, 3308, 3309, 3310];
|
|
21
25
|
const AUTH_WAIT_MS = 15000;
|
|
22
26
|
const AUTH_POLL_MS = 500;
|
|
27
|
+
const MAX_SESSION_AGE_MS = parseInt(process.env.NINJA_MAX_SESSION_AGE_MS || '3600000', 10);
|
|
23
28
|
|
|
24
29
|
const USAGE = `
|
|
25
30
|
ninja-ensure — Start, discover, or recover a dispatch-ready Ninja Terminal runtime
|
|
26
31
|
|
|
27
32
|
Usage:
|
|
28
|
-
ninja-ensure
|
|
29
|
-
ninja-ensure --no-open
|
|
30
|
-
ninja-ensure --
|
|
31
|
-
ninja-ensure --
|
|
32
|
-
ninja-ensure --
|
|
33
|
+
ninja-ensure Start or reuse a matching runtime, open browser, wait for auth
|
|
34
|
+
ninja-ensure --no-open Start or reuse a matching runtime, don't open browser
|
|
35
|
+
ninja-ensure --mode <mode> Override fleet mode for this explicit launch
|
|
36
|
+
ninja-ensure --terminals <n> Override terminal count for this explicit launch
|
|
37
|
+
ninja-ensure --fresh Ignore any existing runtime/session and start fresh
|
|
38
|
+
ninja-ensure --recover-orphan Probe for and recover an orphaned runtime if no session matches
|
|
39
|
+
ninja-ensure --allow-no-auth Allow success without authToken (dispatch will fail)
|
|
40
|
+
ninja-ensure --dry-run Report what would happen, no action
|
|
41
|
+
ninja-ensure --help Show this help
|
|
33
42
|
|
|
34
43
|
Dispatch-ready means:
|
|
35
44
|
- Ninja Terminal server is running and healthy
|
|
@@ -48,6 +57,14 @@ function log(msg) {
|
|
|
48
57
|
console.log(msg);
|
|
49
58
|
}
|
|
50
59
|
|
|
60
|
+
function getArgValue(args, flag) {
|
|
61
|
+
const index = args.indexOf(flag);
|
|
62
|
+
if (index < 0 || index + 1 >= args.length) return null;
|
|
63
|
+
const value = args[index + 1];
|
|
64
|
+
if (!value || value.startsWith('--')) return null;
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
51
68
|
function chromeHasUrl(url) {
|
|
52
69
|
if (process.platform !== 'darwin') return false;
|
|
53
70
|
try {
|
|
@@ -95,6 +112,40 @@ function openBrowser(url) {
|
|
|
95
112
|
}
|
|
96
113
|
}
|
|
97
114
|
|
|
115
|
+
function normalizePathForCompare(value) {
|
|
116
|
+
if (!value) return null;
|
|
117
|
+
return path.resolve(String(value));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getSessionAgeMs(session) {
|
|
121
|
+
if (!session) return null;
|
|
122
|
+
const rawTs = session.updatedAt || session.createdAt;
|
|
123
|
+
if (!rawTs) return null;
|
|
124
|
+
const ts = Date.parse(rawTs);
|
|
125
|
+
if (!Number.isFinite(ts)) return null;
|
|
126
|
+
return Date.now() - ts;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sessionMatchesLaunchConfig(session, requestedCwd, launchConfig) {
|
|
130
|
+
if (!session || !session.launchConfig) return false;
|
|
131
|
+
|
|
132
|
+
const sessionMode = session.launchConfig.mode || 'claude';
|
|
133
|
+
const sessionCount = parseInt(session.launchConfig.terminalCount || session.terminals || 0, 10);
|
|
134
|
+
const sessionCwd = normalizePathForCompare(session.cwd);
|
|
135
|
+
const requestedPath = normalizePathForCompare(requestedCwd);
|
|
136
|
+
|
|
137
|
+
if (sessionMode !== launchConfig.mode) return false;
|
|
138
|
+
if (sessionCount !== launchConfig.terminalCount) return false;
|
|
139
|
+
if (!sessionCwd || !requestedPath || sessionCwd !== requestedPath) return false;
|
|
140
|
+
|
|
141
|
+
if (Number.isFinite(MAX_SESSION_AGE_MS) && MAX_SESSION_AGE_MS > 0) {
|
|
142
|
+
const ageMs = getSessionAgeMs(session);
|
|
143
|
+
if (ageMs == null || ageMs > MAX_SESSION_AGE_MS) return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
98
149
|
function getTokenFromSources(session) {
|
|
99
150
|
if (process.env.NINJA_AUTH_TOKEN) return process.env.NINJA_AUTH_TOKEN;
|
|
100
151
|
if (session?.authToken) return session.authToken;
|
|
@@ -161,16 +212,17 @@ async function probeForOrphanedServer() {
|
|
|
161
212
|
return found || null;
|
|
162
213
|
}
|
|
163
214
|
|
|
164
|
-
function recoverSessionFile(port, host = 'localhost') {
|
|
215
|
+
function recoverSessionFile(port, host = 'localhost', cwd = process.cwd(), launchConfig = null) {
|
|
165
216
|
const url = `http://${host}:${port}`;
|
|
166
217
|
const session = writeRuntimeSession({
|
|
167
218
|
port,
|
|
168
219
|
url,
|
|
169
|
-
cwd
|
|
170
|
-
terminals: 4,
|
|
220
|
+
cwd,
|
|
221
|
+
terminals: launchConfig?.terminalCount || 4,
|
|
171
222
|
command: 'ninja-ensure',
|
|
172
223
|
recovered: true,
|
|
173
224
|
recoveredAt: new Date().toISOString(),
|
|
225
|
+
launchConfig: launchConfig || inferNinjaLaunchConfig('open ninja terminals'),
|
|
174
226
|
pid: null, // Don't claim a pid we don't own
|
|
175
227
|
});
|
|
176
228
|
return session;
|
|
@@ -189,14 +241,18 @@ async function waitForSession(timeoutMs = 20000, pollMs = 300) {
|
|
|
189
241
|
return null;
|
|
190
242
|
}
|
|
191
243
|
|
|
192
|
-
async function startServer() {
|
|
244
|
+
async function startServer({ cwd = process.cwd(), launchConfig = null } = {}) {
|
|
193
245
|
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
194
246
|
const logStream = fs.openSync(LOG_FILE, 'a');
|
|
247
|
+
const effectiveLaunchConfig = launchConfig || inferNinjaLaunchConfig('open ninja terminals');
|
|
195
248
|
|
|
196
249
|
const child = spawn('node', ['server.js'], {
|
|
197
250
|
cwd: PROJECT_ROOT,
|
|
198
251
|
env: {
|
|
199
252
|
...process.env,
|
|
253
|
+
DEFAULT_CWD: cwd,
|
|
254
|
+
NINJA_MODE: effectiveLaunchConfig.mode,
|
|
255
|
+
DEFAULT_TERMINALS: String(effectiveLaunchConfig.terminalCount || 4),
|
|
200
256
|
NINJA_OPEN_BROWSER: 'false',
|
|
201
257
|
},
|
|
202
258
|
detached: true,
|
|
@@ -228,6 +284,21 @@ async function main() {
|
|
|
228
284
|
const noOpen = args.includes('--no-open');
|
|
229
285
|
const dryRun = args.includes('--dry-run');
|
|
230
286
|
const allowNoAuth = args.includes('--allow-no-auth');
|
|
287
|
+
const fresh = args.includes('--fresh');
|
|
288
|
+
const recoverOrphan = args.includes('--recover-orphan');
|
|
289
|
+
const requestedMode = getArgValue(args, '--mode');
|
|
290
|
+
const requestedTerminalsRaw = getArgValue(args, '--terminals');
|
|
291
|
+
const requestedTerminals = requestedTerminalsRaw ? parseInt(requestedTerminalsRaw, 10) : null;
|
|
292
|
+
const request = readNinjaRequest();
|
|
293
|
+
const inferredLaunchConfig = request?.launchConfig || inferNinjaLaunchConfig(request?.promptPreview || '');
|
|
294
|
+
const launchConfig = {
|
|
295
|
+
mode: requestedMode || inferredLaunchConfig.mode,
|
|
296
|
+
terminalCount: Number.isInteger(requestedTerminals) && requestedTerminals > 0
|
|
297
|
+
? requestedTerminals
|
|
298
|
+
: inferredLaunchConfig.terminalCount,
|
|
299
|
+
};
|
|
300
|
+
const requestedCwd = request?.cwd || process.cwd();
|
|
301
|
+
log(`Requested launch: mode=${launchConfig.mode}, terminals=${launchConfig.terminalCount}, cwd=${requestedCwd}${fresh ? ' (fresh)' : ''}${recoverOrphan ? ' (recover-orphan)' : ''}`);
|
|
231
302
|
|
|
232
303
|
// Step 1: Check existing session file
|
|
233
304
|
const existingSession = readRuntimeSession();
|
|
@@ -236,20 +307,23 @@ async function main() {
|
|
|
236
307
|
|
|
237
308
|
if (existingSession?.port) {
|
|
238
309
|
const health = await healthCheckSession(existingSession);
|
|
239
|
-
if (health.ok) {
|
|
310
|
+
if (health.ok && !fresh && sessionMatchesLaunchConfig(existingSession, requestedCwd, launchConfig)) {
|
|
240
311
|
session = existingSession;
|
|
241
312
|
action = 'reuse';
|
|
313
|
+
} else if (health.ok) {
|
|
314
|
+
log('Healthy session found, but it does not match the requested launch config');
|
|
315
|
+
log('Will start a fresh runtime for this explicit request');
|
|
242
316
|
}
|
|
243
317
|
}
|
|
244
318
|
|
|
245
|
-
// Step 2: If no
|
|
246
|
-
if (!session) {
|
|
247
|
-
log('No
|
|
319
|
+
// Step 2: If no reusable session, optionally probe for orphaned server
|
|
320
|
+
if (!session && recoverOrphan && !fresh) {
|
|
321
|
+
log('No reusable session file. Probing for orphaned Ninja server...');
|
|
248
322
|
const orphaned = await probeForOrphanedServer();
|
|
249
323
|
if (orphaned) {
|
|
250
324
|
action = 'recover';
|
|
251
325
|
if (!dryRun) {
|
|
252
|
-
session = recoverSessionFile(orphaned.port, orphaned.host);
|
|
326
|
+
session = recoverSessionFile(orphaned.port, orphaned.host, requestedCwd, launchConfig);
|
|
253
327
|
log(`Recovered orphaned runtime: http://${orphaned.host}:${orphaned.port}`);
|
|
254
328
|
} else {
|
|
255
329
|
session = { port: orphaned.port, host: orphaned.host, url: `http://${orphaned.host}:${orphaned.port}` };
|
|
@@ -257,6 +331,8 @@ async function main() {
|
|
|
257
331
|
} else {
|
|
258
332
|
action = 'start';
|
|
259
333
|
}
|
|
334
|
+
} else if (!session) {
|
|
335
|
+
action = 'start';
|
|
260
336
|
}
|
|
261
337
|
|
|
262
338
|
// Dry run reporting
|
|
@@ -270,7 +346,7 @@ async function main() {
|
|
|
270
346
|
log(`[dry-run] Would repair session file: ${SESSION_FILE}`);
|
|
271
347
|
log(`[dry-run] authToken will be missing (browser sync required)`);
|
|
272
348
|
} else {
|
|
273
|
-
log(`[dry-run] No runtime found
|
|
349
|
+
log(`[dry-run] No reusable runtime found`);
|
|
274
350
|
log(`[dry-run] Would start new server from ${PROJECT_ROOT}`);
|
|
275
351
|
}
|
|
276
352
|
log(`[dry-run] Would open browser: ${!noOpen}`);
|
|
@@ -279,7 +355,7 @@ async function main() {
|
|
|
279
355
|
|
|
280
356
|
// Step 3: Start new server if needed
|
|
281
357
|
if (action === 'start') {
|
|
282
|
-
session = await startServer();
|
|
358
|
+
session = await startServer({ cwd: requestedCwd, launchConfig });
|
|
283
359
|
log(`Server ready on ${session.url}`);
|
|
284
360
|
} else if (action === 'reuse') {
|
|
285
361
|
log(`Runtime already active: ${session.url}`);
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -3,313 +3,20 @@
|
|
|
3
3
|
const WS_BASE = `ws://${location.host}`;
|
|
4
4
|
const API_BASE = '';
|
|
5
5
|
const AUTH_API = '/api';
|
|
6
|
-
const TOKEN_KEY = 'ninja_token';
|
|
7
|
-
|
|
8
|
-
// Session readiness gate — resolves when session is validated (or validation is skipped)
|
|
9
|
-
let sessionReadyResolve;
|
|
10
|
-
const sessionReady = new Promise(resolve => { sessionReadyResolve = resolve; });
|
|
11
|
-
|
|
12
|
-
// ── Auth Module ──────────────────────────────────────────────
|
|
13
|
-
|
|
14
6
|
const auth = {
|
|
15
7
|
token: null,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
terminalsMax: 2,
|
|
19
|
-
validating: false,
|
|
20
|
-
|
|
21
|
-
init() {
|
|
22
|
-
const stored = localStorage.getItem(TOKEN_KEY);
|
|
23
|
-
if (!stored) return false;
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
// Decode JWT payload (base64url)
|
|
27
|
-
const parts = stored.split('.');
|
|
28
|
-
if (parts.length !== 3) {
|
|
29
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
34
|
-
|
|
35
|
-
// Check expiration
|
|
36
|
-
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
|
37
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
this.token = stored;
|
|
42
|
-
this.user = payload.sub || payload.email || payload.username || null;
|
|
43
|
-
return true;
|
|
44
|
-
} catch {
|
|
45
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
|
|
50
|
-
async tryBootstrap() {
|
|
51
|
-
try {
|
|
52
|
-
const res = await fetch(`${API_BASE}/api/auth/bootstrap`);
|
|
53
|
-
if (!res.ok) return false;
|
|
54
|
-
|
|
55
|
-
const data = await res.json();
|
|
56
|
-
if (!data.token) return false;
|
|
57
|
-
|
|
58
|
-
// Validate token format
|
|
59
|
-
const parts = data.token.split('.');
|
|
60
|
-
if (parts.length !== 3) return false;
|
|
61
|
-
|
|
62
|
-
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
63
|
-
if (payload.exp && payload.exp * 1000 < Date.now()) return false;
|
|
64
|
-
|
|
65
|
-
// Save to localStorage and use
|
|
66
|
-
localStorage.setItem(TOKEN_KEY, data.token);
|
|
67
|
-
this.token = data.token;
|
|
68
|
-
this.user = payload.sub || payload.email || payload.username || null;
|
|
69
|
-
return true;
|
|
70
|
-
} catch {
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
|
|
75
|
-
async login(usernameOrEmail, password) {
|
|
76
|
-
const res = await fetch(`${AUTH_API}/auth/login`, {
|
|
77
|
-
method: 'POST',
|
|
78
|
-
headers: { 'Content-Type': 'application/json' },
|
|
79
|
-
body: JSON.stringify({ username: usernameOrEmail, password }),
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
if (!res.ok) {
|
|
83
|
-
const err = await res.json().catch(() => ({}));
|
|
84
|
-
throw new Error(err.message || 'Login failed');
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const data = await res.json();
|
|
88
|
-
this.token = data.token || data.accessToken;
|
|
89
|
-
localStorage.setItem(TOKEN_KEY, this.token);
|
|
90
|
-
|
|
91
|
-
await this.validateTier();
|
|
92
|
-
return data;
|
|
93
|
-
},
|
|
94
|
-
|
|
95
|
-
async register(username, email, password) {
|
|
96
|
-
const res = await fetch(`${AUTH_API}/auth/register`, {
|
|
97
|
-
method: 'POST',
|
|
98
|
-
headers: { 'Content-Type': 'application/json' },
|
|
99
|
-
body: JSON.stringify({ username, email, password }),
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
if (!res.ok) {
|
|
103
|
-
const err = await res.json().catch(() => ({}));
|
|
104
|
-
throw new Error(err.message || 'Registration failed');
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const data = await res.json();
|
|
108
|
-
this.token = data.token || data.accessToken;
|
|
109
|
-
localStorage.setItem(TOKEN_KEY, this.token);
|
|
110
|
-
|
|
111
|
-
await this.validateTier();
|
|
112
|
-
return data;
|
|
8
|
+
getAuthHeader() {
|
|
9
|
+
return {};
|
|
113
10
|
},
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
method: 'POST',
|
|
118
|
-
headers: { 'Content-Type': 'application/json' },
|
|
119
|
-
body: JSON.stringify({ licenseKey: key }),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
if (!res.ok) {
|
|
123
|
-
const err = await res.json().catch(() => ({}));
|
|
124
|
-
throw new Error(err.message || 'Invalid license key');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const data = await res.json();
|
|
128
|
-
this.token = data.token || data.accessToken;
|
|
129
|
-
localStorage.setItem(TOKEN_KEY, this.token);
|
|
130
|
-
|
|
131
|
-
await this.validateTier();
|
|
132
|
-
return data;
|
|
11
|
+
logout() {},
|
|
12
|
+
init() {
|
|
13
|
+
return true;
|
|
133
14
|
},
|
|
134
|
-
|
|
135
15
|
async validateTier() {
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
const res = await fetch(`${API_BASE}/api/session`, {
|
|
139
|
-
method: 'POST',
|
|
140
|
-
headers: {
|
|
141
|
-
'Content-Type': 'application/json',
|
|
142
|
-
...this.getAuthHeader(),
|
|
143
|
-
},
|
|
144
|
-
body: JSON.stringify({ token: this.token }),
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
if (!res.ok) {
|
|
148
|
-
// 401 = token truly invalid/expired, need re-login
|
|
149
|
-
if (res.status === 401) {
|
|
150
|
-
console.warn('Session validation failed: token invalid');
|
|
151
|
-
this.token = null;
|
|
152
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
153
|
-
return { needsLogin: true };
|
|
154
|
-
}
|
|
155
|
-
// Other errors (500, network) — proceed with defaults
|
|
156
|
-
console.warn('Session validation failed, using defaults');
|
|
157
|
-
return { needsLogin: false };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const data = await res.json();
|
|
161
|
-
this.tier = data.tier || 'free';
|
|
162
|
-
this.terminalsMax = data.terminalsMax || 2;
|
|
163
|
-
if (data.user) this.user = data.user;
|
|
164
|
-
return { needsLogin: false };
|
|
165
|
-
} finally {
|
|
166
|
-
this.validating = false;
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
|
|
170
|
-
async logout() {
|
|
171
|
-
try {
|
|
172
|
-
await fetch(`${API_BASE}/api/session`, {
|
|
173
|
-
method: 'DELETE',
|
|
174
|
-
headers: this.getAuthHeader(),
|
|
175
|
-
});
|
|
176
|
-
} catch {
|
|
177
|
-
// Ignore errors on logout
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
this.token = null;
|
|
181
|
-
this.user = null;
|
|
182
|
-
this.tier = null;
|
|
183
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
184
|
-
|
|
185
|
-
showAuthOverlay();
|
|
186
|
-
},
|
|
187
|
-
|
|
188
|
-
getAuthHeader() {
|
|
189
|
-
return this.token ? { 'Authorization': `Bearer ${this.token}` } : {};
|
|
16
|
+
return { needsLogin: false };
|
|
190
17
|
},
|
|
191
18
|
};
|
|
192
19
|
|
|
193
|
-
// ── Auth UI ──────────────────────────────────────────────────
|
|
194
|
-
|
|
195
|
-
function showAuthOverlay() {
|
|
196
|
-
// Auth disabled - app is free, never show auth overlay
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function hideAuthOverlay() {
|
|
201
|
-
const overlay = document.getElementById('auth-overlay');
|
|
202
|
-
overlay.classList.add('hidden');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function setupAuthForms() {
|
|
206
|
-
const loginForm = document.getElementById('login-form');
|
|
207
|
-
const registerForm = document.getElementById('register-form');
|
|
208
|
-
const licenseForm = document.getElementById('license-form');
|
|
209
|
-
const loginError = document.getElementById('login-error');
|
|
210
|
-
const registerError = document.getElementById('register-error');
|
|
211
|
-
const logoutBtn = document.getElementById('logout-btn');
|
|
212
|
-
const showRegisterLink = document.getElementById('show-register');
|
|
213
|
-
const authToggleText = document.getElementById('auth-toggle-text');
|
|
214
|
-
|
|
215
|
-
// Toggle between login and register
|
|
216
|
-
let showingRegister = false;
|
|
217
|
-
|
|
218
|
-
function toggleAuthMode() {
|
|
219
|
-
showingRegister = !showingRegister;
|
|
220
|
-
if (showingRegister) {
|
|
221
|
-
loginForm.classList.add('hidden');
|
|
222
|
-
registerForm.classList.remove('hidden');
|
|
223
|
-
authToggleText.innerHTML = 'Already have an account? <a href="#" id="show-register">Sign in</a>';
|
|
224
|
-
document.getElementById('register-username').focus();
|
|
225
|
-
} else {
|
|
226
|
-
registerForm.classList.add('hidden');
|
|
227
|
-
loginForm.classList.remove('hidden');
|
|
228
|
-
authToggleText.innerHTML = 'Don\'t have an account? <a href="#" id="show-register">Sign up</a>';
|
|
229
|
-
document.getElementById('login-email').focus();
|
|
230
|
-
}
|
|
231
|
-
// Re-attach click handler to new link
|
|
232
|
-
document.getElementById('show-register').addEventListener('click', (e) => {
|
|
233
|
-
e.preventDefault();
|
|
234
|
-
toggleAuthMode();
|
|
235
|
-
});
|
|
236
|
-
loginError.textContent = '';
|
|
237
|
-
registerError.textContent = '';
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
showRegisterLink.addEventListener('click', (e) => {
|
|
241
|
-
e.preventDefault();
|
|
242
|
-
toggleAuthMode();
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
// Login form
|
|
246
|
-
loginForm.addEventListener('submit', async (e) => {
|
|
247
|
-
e.preventDefault();
|
|
248
|
-
loginError.textContent = '';
|
|
249
|
-
|
|
250
|
-
const email = document.getElementById('login-email').value.trim();
|
|
251
|
-
const password = document.getElementById('login-password').value;
|
|
252
|
-
|
|
253
|
-
try {
|
|
254
|
-
await auth.login(email, password);
|
|
255
|
-
hideAuthOverlay();
|
|
256
|
-
startApp();
|
|
257
|
-
sessionReadyResolve();
|
|
258
|
-
} catch (err) {
|
|
259
|
-
loginError.textContent = err.message;
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
// Register form
|
|
264
|
-
registerForm.addEventListener('submit', async (e) => {
|
|
265
|
-
e.preventDefault();
|
|
266
|
-
registerError.textContent = '';
|
|
267
|
-
|
|
268
|
-
const username = document.getElementById('register-username').value.trim();
|
|
269
|
-
const email = document.getElementById('register-email').value.trim();
|
|
270
|
-
const password = document.getElementById('register-password').value;
|
|
271
|
-
|
|
272
|
-
if (password.length < 8) {
|
|
273
|
-
registerError.textContent = 'Password must be at least 8 characters';
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
await auth.register(username, email, password);
|
|
279
|
-
hideAuthOverlay();
|
|
280
|
-
startApp();
|
|
281
|
-
sessionReadyResolve();
|
|
282
|
-
} catch (err) {
|
|
283
|
-
registerError.textContent = err.message;
|
|
284
|
-
}
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
// License form
|
|
288
|
-
licenseForm.addEventListener('submit', async (e) => {
|
|
289
|
-
e.preventDefault();
|
|
290
|
-
loginError.textContent = '';
|
|
291
|
-
|
|
292
|
-
const key = document.getElementById('license-key').value.trim();
|
|
293
|
-
if (!key) {
|
|
294
|
-
loginError.textContent = 'Please enter a license key';
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
try {
|
|
299
|
-
await auth.activateLicense(key);
|
|
300
|
-
hideAuthOverlay();
|
|
301
|
-
startApp();
|
|
302
|
-
sessionReadyResolve();
|
|
303
|
-
} catch (err) {
|
|
304
|
-
loginError.textContent = err.message;
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
logoutBtn.addEventListener('click', () => {
|
|
309
|
-
auth.logout();
|
|
310
|
-
});
|
|
311
|
-
}
|
|
312
|
-
|
|
313
20
|
// ── State ────────────────────────────────────────────────────
|
|
314
21
|
|
|
315
22
|
const state = {
|
|
@@ -376,6 +83,24 @@ const TASK_STATUS_LABELS = {
|
|
|
376
83
|
unknown: 'UNKNOWN',
|
|
377
84
|
};
|
|
378
85
|
|
|
86
|
+
const AGENT_TYPE_LABELS = {
|
|
87
|
+
claude: 'Claude',
|
|
88
|
+
codex: 'Codex',
|
|
89
|
+
opencode: 'OpenCode',
|
|
90
|
+
shell: 'Shell',
|
|
91
|
+
mixed: 'Mixed',
|
|
92
|
+
duo: 'Duo',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const AGENT_TYPE_CLASSES = {
|
|
96
|
+
claude: 'agent-claude',
|
|
97
|
+
codex: 'agent-codex',
|
|
98
|
+
opencode: 'agent-opencode',
|
|
99
|
+
shell: 'agent-shell',
|
|
100
|
+
mixed: 'agent-mixed',
|
|
101
|
+
duo: 'agent-duo',
|
|
102
|
+
};
|
|
103
|
+
|
|
379
104
|
// ── Utilities ────────────────────────────────────────────────
|
|
380
105
|
|
|
381
106
|
function timestamp() {
|
|
@@ -393,6 +118,10 @@ function getTerminalFeedClass(id) {
|
|
|
393
118
|
return `feed-t${idx + 1}`;
|
|
394
119
|
}
|
|
395
120
|
|
|
121
|
+
function getAgentTypeClass(agentType) {
|
|
122
|
+
return AGENT_TYPE_CLASSES[agentType] || 'agent-claude';
|
|
123
|
+
}
|
|
124
|
+
|
|
396
125
|
// ── Activity Feed ────────────────────────────────────────────
|
|
397
126
|
|
|
398
127
|
function addFeedEntry(message, terminalId) {
|
|
@@ -423,12 +152,13 @@ function escapeHtml(str) {
|
|
|
423
152
|
// ── Terminal Creation ────────────────────────────────────────
|
|
424
153
|
|
|
425
154
|
function createTerminalUI(termData) {
|
|
426
|
-
const { id, label, status, elapsed, progress, taskName } = termData;
|
|
155
|
+
const { id, label, status, elapsed, progress, taskName, agentType } = termData;
|
|
427
156
|
|
|
428
157
|
// Pane
|
|
429
158
|
const pane = document.createElement('div');
|
|
430
159
|
pane.className = 'terminal-pane';
|
|
431
160
|
pane.id = `pane-${id}`;
|
|
161
|
+
pane.classList.add(getAgentTypeClass(agentType));
|
|
432
162
|
|
|
433
163
|
// Header
|
|
434
164
|
const header = document.createElement('div');
|
|
@@ -660,6 +390,7 @@ function createTerminalUI(termData) {
|
|
|
660
390
|
const termState = {
|
|
661
391
|
id,
|
|
662
392
|
label: label || `Terminal ${id.slice(0, 6)}`,
|
|
393
|
+
agentType: agentType || 'claude',
|
|
663
394
|
status: status || 'idle',
|
|
664
395
|
progress: progress || 0,
|
|
665
396
|
elapsed: elapsed || '',
|
|
@@ -964,6 +695,11 @@ function updateTerminalState(id, newStatus, extra) {
|
|
|
964
695
|
if (t.labelEl) t.labelEl.textContent = extra.label;
|
|
965
696
|
}
|
|
966
697
|
if (extra.taskName !== undefined) t.taskName = extra.taskName;
|
|
698
|
+
if (extra.agentType !== undefined) {
|
|
699
|
+
t.agentType = extra.agentType;
|
|
700
|
+
t.paneEl.classList.remove('agent-claude', 'agent-codex', 'agent-opencode', 'agent-shell', 'agent-mixed', 'agent-duo');
|
|
701
|
+
t.paneEl.classList.add(getAgentTypeClass(extra.agentType));
|
|
702
|
+
}
|
|
967
703
|
}
|
|
968
704
|
|
|
969
705
|
// Update state icon
|
|
@@ -1248,7 +984,6 @@ window.addEventListener('resize', () => {
|
|
|
1248
984
|
// ── Start App (after auth) ───────────────────────────────────
|
|
1249
985
|
|
|
1250
986
|
async function startApp() {
|
|
1251
|
-
// Request desktop notification permission
|
|
1252
987
|
requestNotificationPermission();
|
|
1253
988
|
|
|
1254
989
|
// Setup sidebar
|
|
@@ -1294,23 +1029,37 @@ async function startApp() {
|
|
|
1294
1029
|
|
|
1295
1030
|
function setupAddTerminal() {
|
|
1296
1031
|
const btn = document.getElementById('add-terminal-btn');
|
|
1032
|
+
const preset = document.getElementById('agent-preset-select');
|
|
1297
1033
|
if (!btn) return;
|
|
1298
1034
|
|
|
1299
1035
|
// Store last used directory
|
|
1300
1036
|
let lastCwd = localStorage.getItem('ninja-last-cwd') || '/Users/davidmini/Desktop/Projects';
|
|
1037
|
+
let lastAgentType = localStorage.getItem('ninja-last-agent-type') || 'claude';
|
|
1038
|
+
|
|
1039
|
+
if (preset) {
|
|
1040
|
+
const validPresets = new Set(['claude', 'codex', 'opencode', 'shell', 'mixed', 'duo']);
|
|
1041
|
+
if (!validPresets.has(lastAgentType)) lastAgentType = 'claude';
|
|
1042
|
+
preset.value = lastAgentType;
|
|
1043
|
+
preset.addEventListener('change', () => {
|
|
1044
|
+
lastAgentType = preset.value;
|
|
1045
|
+
localStorage.setItem('ninja-last-agent-type', lastAgentType);
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1301
1048
|
|
|
1302
1049
|
btn.addEventListener('click', async () => {
|
|
1303
1050
|
try {
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1051
|
+
const agentType = preset?.value || lastAgentType || 'claude';
|
|
1052
|
+
const cwd = lastCwd;
|
|
1306
1053
|
|
|
1307
1054
|
lastCwd = cwd;
|
|
1055
|
+
lastAgentType = agentType;
|
|
1308
1056
|
localStorage.setItem('ninja-last-cwd', cwd);
|
|
1057
|
+
localStorage.setItem('ninja-last-agent-type', agentType);
|
|
1309
1058
|
|
|
1310
1059
|
const res = await fetch(`${API_BASE}/api/terminals`, {
|
|
1311
1060
|
method: 'POST',
|
|
1312
1061
|
headers: { 'Content-Type': 'application/json', ...auth.getAuthHeader() },
|
|
1313
|
-
body: JSON.stringify({ cwd }),
|
|
1062
|
+
body: JSON.stringify({ cwd, agentType }),
|
|
1314
1063
|
});
|
|
1315
1064
|
|
|
1316
1065
|
if (!res.ok) {
|
|
@@ -1321,7 +1070,7 @@ function setupAddTerminal() {
|
|
|
1321
1070
|
|
|
1322
1071
|
const terminal = await res.json();
|
|
1323
1072
|
createTerminalUI(terminal);
|
|
1324
|
-
addFeedEntry(`Terminal added: T${terminal.id}`);
|
|
1073
|
+
addFeedEntry(`Terminal added: T${terminal.id} (${AGENT_TYPE_LABELS[terminal.agentType] || terminal.agentType || 'claude'})`);
|
|
1325
1074
|
} catch (err) {
|
|
1326
1075
|
console.error('Failed to add terminal:', err);
|
|
1327
1076
|
alert('Failed to add terminal');
|
|
@@ -1401,10 +1150,8 @@ function setupLearnings() {
|
|
|
1401
1150
|
// ── Initialize ───────────────────────────────────────────────
|
|
1402
1151
|
|
|
1403
1152
|
async function init() {
|
|
1404
|
-
|
|
1405
|
-
hideAuthOverlay();
|
|
1153
|
+
auth.init();
|
|
1406
1154
|
startApp();
|
|
1407
|
-
sessionReadyResolve();
|
|
1408
1155
|
}
|
|
1409
1156
|
|
|
1410
1157
|
init();
|