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.
- package/CLAUDE.md +2 -379
- package/README.md +5 -1
- package/cli.js +34 -118
- package/lib/ninja-request.js +247 -0
- package/lib/settings-gen.js +0 -13
- package/mcp-server.js +22 -37
- package/ninja-ensure.js +92 -16
- package/package.json +2 -3
- package/public/app.js +55 -308
- package/public/index.html +15 -44
- package/public/style.css +78 -6
- package/server.js +31 -12
- package/hooks/ninja-common.js +0 -46
- package/hooks/ninja-prompt-submit.js +0 -33
- package/hooks/ninja-startup.js +0 -95
|
@@ -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
|
+
};
|
package/lib/settings-gen.js
CHANGED
|
@@ -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 --
|
|
46
|
-
|
|
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
|
|
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}"
|
|
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
|
-
|
|
944
|
-
|
|
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
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ninja-terminals",
|
|
3
|
-
"version": "2.4.
|
|
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",
|