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/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 Start/discover/recover, open browser, wait for auth
29
- ninja-ensure --no-open Start/discover/recover, don't open browser
30
- ninja-ensure --allow-no-auth Allow success without authToken (dispatch will fail)
31
- ninja-ensure --dry-run Report what would happen, no action
32
- ninja-ensure --help Show this help
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: process.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 healthy session, probe for orphaned server
246
- if (!session) {
247
- log('No healthy session file. Probing for orphaned Ninja server...');
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 on probed ports`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninja-terminals",
3
- "version": "2.3.9",
3
+ "version": "2.4.1",
4
4
  "description": "MCP server for multi-terminal Claude Code orchestration with DAG task management, parallel execution, and self-improvement",
5
5
  "main": "server.js",
6
6
  "bin": {
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
- user: null,
17
- tier: null,
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
- async activateLicense(key) {
116
- const res = await fetch(`${AUTH_API}/ninja/activate-license`, {
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
- this.validating = true;
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 cwd = prompt('Working directory:', lastCwd);
1305
- if (!cwd) return;
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
- // Auth disabled — app is free, start immediately
1405
- hideAuthOverlay();
1153
+ auth.init();
1406
1154
  startApp();
1407
- sessionReadyResolve();
1408
1155
  }
1409
1156
 
1410
1157
  init();