ninja-terminals 2.4.0 → 2.4.2

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.
@@ -0,0 +1,247 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const NINJA_DIR = path.join(os.homedir(), '.ninja');
8
+ const REQUEST_FILE = path.join(NINJA_DIR, 'ninja-request.json');
9
+ const LEDGER_FILE = path.join(NINJA_DIR, 'dispatch-ledger.ndjson');
10
+ const VERIFICATION_LEDGER_FILE = path.join(NINJA_DIR, 'verification-ledger.ndjson');
11
+ const VISUAL_LEDGER_FILE = path.join(NINJA_DIR, 'visual-ledger.ndjson');
12
+
13
+ const NINJA_PATTERNS = [
14
+ /(?:^|\b)(?:open|start|launch|spawn|bring up)\s+(?:the\s+)?ninja\s*terminals?\b/i,
15
+ /(?:^|\b)ninja\s*terminals?\s+(?:please|now)\b/i,
16
+ ];
17
+
18
+ const MODE_PATTERNS = [
19
+ { mode: 'shell', pattern: /(?:terminals?|shell)\s+only/i, defaultCount: 4 },
20
+ { mode: 'claude', pattern: /claude\s+only/i, defaultCount: 4 },
21
+ { mode: 'codex', pattern: /codex\s+only/i, defaultCount: 4 },
22
+ { mode: 'opencode', pattern: /(?:open\s*code|opencode)\s+only/i, defaultCount: 4 },
23
+ { mode: 'mixed', pattern: /\bmixed\b/i, defaultCount: 4 },
24
+ { mode: 'duo', pattern: /\bduo\b/i, defaultCount: 2 },
25
+ ];
26
+
27
+ const NUMBER_WORDS = new Map([
28
+ ['one', 1],
29
+ ['two', 2],
30
+ ['three', 3],
31
+ ['four', 4],
32
+ ['five', 5],
33
+ ['six', 6],
34
+ ['seven', 7],
35
+ ['eight', 8],
36
+ ]);
37
+
38
+ function isNinjaRequest(prompt) {
39
+ if (!prompt) return false;
40
+ return NINJA_PATTERNS.some(pattern => pattern.test(prompt));
41
+ }
42
+
43
+ function inferNinjaLaunchConfig(prompt) {
44
+ const text = String(prompt || '');
45
+ const modeEntry = MODE_PATTERNS.find(entry => entry.pattern.test(text));
46
+ const mode = modeEntry ? modeEntry.mode : 'claude';
47
+ const defaultCount = modeEntry ? modeEntry.defaultCount : 4;
48
+
49
+ const numericCount = text.match(/\b(\d+)\s*(?:terminals?|instances?)\b/i);
50
+ const wordCount = text.match(/\b(one|two|three|four|five|six|seven|eight)\s*(?:terminals?|instances?)\b/i);
51
+ const requestedCount = numericCount
52
+ ? parseInt(numericCount[1], 10)
53
+ : wordCount
54
+ ? NUMBER_WORDS.get(wordCount[1].toLowerCase())
55
+ : null;
56
+
57
+ const terminalCount = Number.isInteger(requestedCount) && requestedCount > 0
58
+ ? requestedCount
59
+ : defaultCount;
60
+
61
+ return { mode, terminalCount };
62
+ }
63
+
64
+ function writeNinjaRequest(cwd, promptPreview) {
65
+ try {
66
+ fs.mkdirSync(NINJA_DIR, { recursive: true, mode: 0o700 });
67
+ const launchConfig = inferNinjaLaunchConfig(promptPreview);
68
+ const data = {
69
+ timestamp: new Date().toISOString(),
70
+ cwd: cwd || process.cwd(),
71
+ promptPreview: (promptPreview || '').slice(0, 200),
72
+ launchConfig,
73
+ };
74
+ fs.writeFileSync(REQUEST_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
75
+ return data;
76
+ } catch (err) {
77
+ console.error(`Warning: Could not write ninja request: ${err.message}`);
78
+ return null;
79
+ }
80
+ }
81
+
82
+ function readNinjaRequest() {
83
+ try {
84
+ const raw = fs.readFileSync(REQUEST_FILE, 'utf8');
85
+ return JSON.parse(raw);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function clearNinjaRequest() {
92
+ try {
93
+ fs.unlinkSync(REQUEST_FILE);
94
+ } catch {
95
+ // already absent
96
+ }
97
+ }
98
+
99
+ function readLedgerAfter(afterTimestamp) {
100
+ try {
101
+ const raw = fs.readFileSync(LEDGER_FILE, 'utf8');
102
+ const lines = raw.trim().split('\n').filter(Boolean);
103
+ const entries = [];
104
+ for (const line of lines) {
105
+ try {
106
+ const entry = JSON.parse(line);
107
+ if (entry.timestamp && entry.timestamp > afterTimestamp) {
108
+ entries.push(entry);
109
+ }
110
+ } catch {
111
+ // skip malformed
112
+ }
113
+ }
114
+ return entries;
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+
120
+ function hasSuccessfulDispatchAfter(afterTimestamp) {
121
+ const entries = readLedgerAfter(afterTimestamp);
122
+ return entries.some(e => e.success === true);
123
+ }
124
+
125
+ function readVerificationLedgerAfter(afterTimestamp) {
126
+ try {
127
+ const raw = fs.readFileSync(VERIFICATION_LEDGER_FILE, 'utf8');
128
+ const lines = raw.trim().split('\n').filter(Boolean);
129
+ const entries = [];
130
+ for (const line of lines) {
131
+ try {
132
+ const entry = JSON.parse(line);
133
+ if (entry.timestamp && entry.timestamp > afterTimestamp) {
134
+ entries.push(entry);
135
+ }
136
+ } catch {
137
+ // skip malformed
138
+ }
139
+ }
140
+ return entries;
141
+ } catch {
142
+ return [];
143
+ }
144
+ }
145
+
146
+ function hasVerificationAfter(afterTimestamp) {
147
+ const entries = readVerificationLedgerAfter(afterTimestamp);
148
+ return entries.length > 0;
149
+ }
150
+
151
+ function getLastSuccessfulDispatchTimestamp(afterTimestamp) {
152
+ const entries = readLedgerAfter(afterTimestamp);
153
+ const successful = entries.filter(e => e.success === true);
154
+ if (successful.length === 0) return null;
155
+ return successful[successful.length - 1].timestamp;
156
+ }
157
+
158
+ function getLastVerificationTimestamp(afterTimestamp) {
159
+ const entries = readVerificationLedgerAfter(afterTimestamp);
160
+ if (entries.length === 0) return null;
161
+ return entries[entries.length - 1].timestamp;
162
+ }
163
+
164
+ function readVisualLedgerAfter(afterTimestamp) {
165
+ try {
166
+ const raw = fs.readFileSync(VISUAL_LEDGER_FILE, 'utf8');
167
+ const lines = raw.trim().split('\n').filter(Boolean);
168
+ const entries = [];
169
+ for (const line of lines) {
170
+ try {
171
+ const entry = JSON.parse(line);
172
+ if (entry.timestamp && entry.timestamp > afterTimestamp) {
173
+ entries.push(entry);
174
+ }
175
+ } catch {
176
+ // skip malformed
177
+ }
178
+ }
179
+ return entries;
180
+ } catch {
181
+ return [];
182
+ }
183
+ }
184
+
185
+ function readVisualLedgerBetween(startTs, endTs) {
186
+ try {
187
+ const raw = fs.readFileSync(VISUAL_LEDGER_FILE, 'utf8');
188
+ const lines = raw.trim().split('\n').filter(Boolean);
189
+ const entries = [];
190
+ for (const line of lines) {
191
+ try {
192
+ const entry = JSON.parse(line);
193
+ if (entry.timestamp && entry.timestamp > startTs && entry.timestamp < endTs) {
194
+ entries.push(entry);
195
+ }
196
+ } catch {
197
+ // skip malformed
198
+ }
199
+ }
200
+ return entries;
201
+ } catch {
202
+ return [];
203
+ }
204
+ }
205
+
206
+ function hasVisualBetween(startTs, endTs, options = {}) {
207
+ const entries = readVisualLedgerBetween(startTs, endTs);
208
+ return entries.some(e => {
209
+ if (options.stage && e.stage !== options.stage) return false;
210
+ if (options.stages && !options.stages.includes(e.stage)) return false;
211
+ if (options.source && e.source !== options.source) return false;
212
+ return true;
213
+ });
214
+ }
215
+
216
+ function hasVisualAfter(afterTimestamp, options = {}) {
217
+ const entries = readVisualLedgerAfter(afterTimestamp);
218
+ return entries.some(e => {
219
+ if (options.stage && e.stage !== options.stage) return false;
220
+ if (options.stages && !options.stages.includes(e.stage)) return false;
221
+ if (options.source && e.source !== options.source) return false;
222
+ return true;
223
+ });
224
+ }
225
+
226
+ module.exports = {
227
+ NINJA_DIR,
228
+ REQUEST_FILE,
229
+ LEDGER_FILE,
230
+ VERIFICATION_LEDGER_FILE,
231
+ VISUAL_LEDGER_FILE,
232
+ isNinjaRequest,
233
+ inferNinjaLaunchConfig,
234
+ writeNinjaRequest,
235
+ readNinjaRequest,
236
+ clearNinjaRequest,
237
+ readLedgerAfter,
238
+ hasSuccessfulDispatchAfter,
239
+ readVerificationLedgerAfter,
240
+ hasVerificationAfter,
241
+ getLastSuccessfulDispatchTimestamp,
242
+ getLastVerificationTimestamp,
243
+ readVisualLedgerAfter,
244
+ readVisualLedgerBetween,
245
+ hasVisualBetween,
246
+ hasVisualAfter,
247
+ };
@@ -192,22 +192,9 @@ function writeWorkerSettings(terminalId, projectDir, scope, options = {}) {
192
192
  const mergedAllow = [...new Set([...(existing.permissions?.allow || []), ...settings.permissions.allow])];
193
193
  const mergedDeny = [...new Set([...(existing.permissions?.deny || []), ...settings.permissions.deny])];
194
194
 
195
- // Build hooks for self-improvement loop (matches Claude Code's nested format)
196
- const ninjaDir = path.resolve(__dirname, '..');
197
- const hooks = {
198
- PostToolUse: [{
199
- matcher: '',
200
- hooks: [{
201
- type: 'command',
202
- command: path.join(ninjaDir, '.claude/hooks/track-tool.sh'),
203
- }],
204
- }],
205
- };
206
-
207
195
  const merged = {
208
196
  ...existing,
209
197
  permissions: { allow: mergedAllow, deny: mergedDeny },
210
- hooks,
211
198
  sandbox: settings.sandbox,
212
199
  };
213
200
 
package/mcp-server.js CHANGED
@@ -40,10 +40,13 @@ const {
40
40
  } = require('./lib/runtime-session');
41
41
 
42
42
  // ── Config ──────────────────────────────────────────────────
43
- const PREFERRED_HTTP_PORT = parseInt(process.env.HTTP_PORT || '3300', 10);
43
+ const PREFERRED_HTTP_PORT = parseInt(process.env.HTTP_PORT || process.env.PORT || '3300', 10);
44
44
  let HTTP_PORT = PREFERRED_HTTP_PORT;
45
- const CLAUDE_CMD = process.env.CLAUDE_CMD || 'claude --dangerously-skip-permissions';
46
- const SHELL = process.env.SHELL || '/bin/zsh';
45
+ const CLAUDE_CMD = process.env.CLAUDE_CMD || process.env.CLAUDE_CHROME_CMD || 'claude --chrome --model claude-opus-4-5-20251101';
46
+ // Windows has no $SHELL / /bin/zsh — default to PowerShell (handles drive
47
+ // changes via `cd` and emits clean ANSI for status detection).
48
+ const IS_WIN = process.platform === 'win32';
49
+ const SHELL = IS_WIN ? (process.env.NINJA_SHELL || 'powershell.exe') : (process.env.SHELL || '/bin/zsh');
47
50
  const PROJECT_DIR = __dirname;
48
51
  const INJECT_GUIDANCE = process.env.INJECT_GUIDANCE !== 'false';
49
52
 
@@ -151,16 +154,27 @@ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro') {
151
154
  env: {
152
155
  ...cleanEnv,
153
156
  TERM: 'xterm-256color',
157
+ COLORTERM: 'truecolor',
158
+ FORCE_COLOR: '1',
159
+ CLICOLOR_FORCE: '1',
154
160
  HOME: os.homedir(),
155
- PATH: `${os.homedir()}/.local/bin:/opt/homebrew/bin:${process.env.PATH || ''}`,
161
+ // On Windows, keep the native PATH untouched (mac/linux bin dirs would
162
+ // corrupt it via the ';' delimiter). On POSIX, prepend the usual bins.
163
+ PATH: IS_WIN
164
+ ? (process.env.PATH || '')
165
+ : [`${os.homedir()}/.local/bin`, '/opt/homebrew/bin', process.env.PATH || '']
166
+ .filter(Boolean).join(path.delimiter),
156
167
  SHELL_SESSIONS_DISABLE: '1',
157
168
  NINJA_TERMINAL_ID: String(id),
158
169
  },
159
170
  });
160
171
 
161
- // Launch claude after shell starts
172
+ // Launch claude after shell starts. Two separate writes (cd, then command)
173
+ // instead of `&&` — works across bash/zsh/PowerShell/cmd, which disagree on
174
+ // command chaining (`&&` is unsupported in Windows PowerShell 5).
162
175
  setTimeout(() => {
163
- ptyProcess.write(`cd "${workDir}" && ${CLAUDE_CMD}\r`);
176
+ ptyProcess.write(`cd "${workDir}"\r`);
177
+ setTimeout(() => ptyProcess.write(`${CLAUDE_CMD}\r`), 250);
164
178
  }, 500);
165
179
 
166
180
  const terminal = {
@@ -940,37 +954,8 @@ async function main() {
940
954
  console.error(`Ninja Terminals server already running on port ${HTTP_PORT}`);
941
955
  console.error('MCP server starting in proxy mode (will use existing server)');
942
956
  } else {
943
- HTTP_PORT = await findAvailablePort(PREFERRED_HTTP_PORT);
944
- if (HTTP_PORT !== PREFERRED_HTTP_PORT) {
945
- console.error(`Ninja Terminals preferred HTTP port ${PREFERRED_HTTP_PORT} unavailable; using ${HTTP_PORT}`);
946
- }
947
-
948
- // Standalone mode: start our own HTTP server
949
- httpServer.listen(HTTP_PORT, () => {
950
- const url = `http://localhost:${HTTP_PORT}`;
951
- writeRuntimeSession({
952
- port: HTTP_PORT,
953
- url,
954
- cwd: PROJECT_DIR,
955
- terminals: parseInt(process.env.NINJA_TERMINAL_COUNT || '2', 10),
956
- command: 'ninja-terminals-mcp',
957
- });
958
- ownsRuntimeSession = true;
959
- console.error(`Ninja Terminals HTTP server running on ${url}`);
960
-
961
- // Auto-spawn terminals based on tier (NINJA_TERMINAL_COUNT env var)
962
- // Free = 2, Paid = 4
963
- const terminalCount = parseInt(process.env.NINJA_TERMINAL_COUNT || '2', 10);
964
- const labels = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8'];
965
-
966
- console.error(`Auto-spawning ${terminalCount} terminals...`);
967
- for (let i = 0; i < terminalCount; i++) {
968
- const label = labels[i] || `T${i + 1}`;
969
- spawnTerminal(label, [], process.cwd(), 'pro');
970
- console.error(` Spawned ${label}`);
971
- }
972
- console.error(`All ${terminalCount} terminals ready`);
973
- });
957
+ console.error('No live Ninja runtime found. MCP is staying request-only until ninja-ensure starts the runtime.');
958
+ console.error('No HTTP server, no session file, no terminal spawn on Claude startup.');
974
959
  }
975
960
 
976
961
  // Start MCP server on stdio
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.4.0",
3
+ "version": "2.4.2",
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": {
@@ -49,8 +49,7 @@
49
49
  "ninja-logout.js",
50
50
  "ninja-whoami.js",
51
51
  "CLAUDE.md",
52
- "ORCHESTRATOR-PROMPT.md",
53
- "hooks/"
52
+ "ORCHESTRATOR-PROMPT.md"
54
53
  ],
55
54
  "keywords": [
56
55
  "claude",