ninja-terminals 2.4.7 → 2.4.9
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/cli.js +1 -1
- package/lib/runtime-session.js +109 -3
- package/mcp-server.js +3 -2
- package/ninja-ensure.js +11 -4
- package/package.json +1 -1
- package/public/app.js +2 -1
- package/public/index.html +5 -0
- package/server.js +17 -7
package/cli.js
CHANGED
|
@@ -185,7 +185,7 @@ MCP tools available after restart:
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
const port = parseInt(getArg('--port', '3300'), 10);
|
|
188
|
-
const terminals = parseInt(getArg('--terminals', '
|
|
188
|
+
const terminals = parseInt(getArg('--terminals', '4'), 10); // Default fleet size
|
|
189
189
|
const cwd = getArg('--cwd', process.cwd());
|
|
190
190
|
const token = getArg('--token', null);
|
|
191
191
|
const offline = hasFlag('--offline');
|
package/lib/runtime-session.js
CHANGED
|
@@ -7,8 +7,14 @@ const os = require('os');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { spawn, execSync } = require('child_process');
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// NINJA_HOME lets a second, isolated Ninja runtime coexist with another agent's default
|
|
11
|
+
// runtime: it gets its own session.json/lock so the two never SIGTERM each other on launch.
|
|
12
|
+
// Defaults to ~/.ninja, so existing single-runtime behavior is unchanged.
|
|
13
|
+
const SESSION_DIR = path.join(process.env.NINJA_HOME || os.homedir(), '.ninja');
|
|
11
14
|
const SESSION_FILE = path.join(SESSION_DIR, 'session.json');
|
|
15
|
+
// Per-port session files live here, one per running runtime. session.json above
|
|
16
|
+
// stays as a "most-recent" pointer so existing readers keep working unchanged.
|
|
17
|
+
const SESSIONS_DIR = path.join(SESSION_DIR, 'sessions');
|
|
12
18
|
const TOKEN_FILE = path.join(SESSION_DIR, 'token');
|
|
13
19
|
const LEDGER_FILE = path.join(SESSION_DIR, 'dispatch-ledger.ndjson');
|
|
14
20
|
const VERIFICATION_LEDGER_FILE = path.join(SESSION_DIR, 'verification-ledger.ndjson');
|
|
@@ -18,6 +24,10 @@ function ensureSessionDir() {
|
|
|
18
24
|
fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
function sessionFileForPort(port) {
|
|
28
|
+
return path.join(SESSIONS_DIR, `${port}.json`);
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
// Probe a single host: 'free' (bindable), 'inuse' (EADDRINUSE), or
|
|
22
32
|
// 'unavailable' (stack absent / other error — don't treat as a collision).
|
|
23
33
|
function probePort(port, host) {
|
|
@@ -66,6 +76,17 @@ async function killStaleRuntime(session, selfPid = process.pid) {
|
|
|
66
76
|
// Is the process even alive?
|
|
67
77
|
try { process.kill(pid, 0); } catch { return false; }
|
|
68
78
|
|
|
79
|
+
// NEVER kill a HEALTHY runtime — it may be another instance doing real work.
|
|
80
|
+
// Only a dead/hung runtime is a "stale" one worth reclaiming. This is the
|
|
81
|
+
// guard that stops a new launch from SIGTERMing a live parallel fleet.
|
|
82
|
+
if (session.port) {
|
|
83
|
+
const health = await healthCheckSession(
|
|
84
|
+
{ host: session.host || '127.0.0.1', port: session.port },
|
|
85
|
+
1500,
|
|
86
|
+
);
|
|
87
|
+
if (health.ok) return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
// Verify it's a Ninja server (avoid killing an unrelated reused PID).
|
|
70
91
|
if (process.platform !== 'win32') {
|
|
71
92
|
try {
|
|
@@ -84,6 +105,40 @@ async function killStaleRuntime(session, selfPid = process.pid) {
|
|
|
84
105
|
}
|
|
85
106
|
}
|
|
86
107
|
|
|
108
|
+
// Reclaim the preferred port from any *Ninja* server still holding it. The
|
|
109
|
+
// pid-based killStaleRuntime only knows the single pid in session.json, so
|
|
110
|
+
// orphans on climbed ports (3301/3302 from earlier crashed relaunches) survive
|
|
111
|
+
// and findAvailablePort climbs past them — each climb is a new server and a new
|
|
112
|
+
// browser tab (the 2/4/6-tab pile-up). Sweeping the port itself kills them all.
|
|
113
|
+
// Only SIGTERMs processes verified as Ninja; a non-Ninja listener is left alone
|
|
114
|
+
// (caller then climbs past it as before). POSIX-only (lsof); no-op elsewhere.
|
|
115
|
+
async function reclaimPort(port, selfPid = process.pid) {
|
|
116
|
+
if (process.platform === 'win32') return 0;
|
|
117
|
+
// Never reclaim a HEALTHY fleet. If the port answers /health, it's a live
|
|
118
|
+
// Ninja instance — leave it alone and let the caller climb to the next port.
|
|
119
|
+
// Only sweep when the listener is dead/hung (a true orphan).
|
|
120
|
+
const holder = await healthCheckSession({ host: '127.0.0.1', port }, 1500);
|
|
121
|
+
if (holder.ok) return 0;
|
|
122
|
+
let pids;
|
|
123
|
+
try {
|
|
124
|
+
pids = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, { encoding: 'utf8' })
|
|
125
|
+
.split('\n').map((s) => parseInt(s, 10)).filter((p) => p && p !== selfPid);
|
|
126
|
+
} catch {
|
|
127
|
+
return 0; // lsof absent or nothing on the port
|
|
128
|
+
}
|
|
129
|
+
let killed = 0;
|
|
130
|
+
for (const pid of new Set(pids)) {
|
|
131
|
+
try {
|
|
132
|
+
const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8' }).trim();
|
|
133
|
+
if (!/server\.js|cli\.js|ninja/i.test(cmd)) continue; // not ours — leave it
|
|
134
|
+
process.kill(pid, 'SIGTERM');
|
|
135
|
+
killed++;
|
|
136
|
+
} catch { /* gone already / not killable */ }
|
|
137
|
+
}
|
|
138
|
+
if (killed) await new Promise((resolve) => setTimeout(resolve, 800)); // let ports release
|
|
139
|
+
return killed;
|
|
140
|
+
}
|
|
141
|
+
|
|
87
142
|
function writeRuntimeSession(session) {
|
|
88
143
|
ensureSessionDir();
|
|
89
144
|
const payload = {
|
|
@@ -93,10 +148,36 @@ function writeRuntimeSession(session) {
|
|
|
93
148
|
createdAt: new Date().toISOString(),
|
|
94
149
|
...session,
|
|
95
150
|
};
|
|
96
|
-
|
|
151
|
+
const json = JSON.stringify(payload, null, 2) + '\n';
|
|
152
|
+
// Pointer: "most-recent runtime" — keeps every existing reader working.
|
|
153
|
+
fs.writeFileSync(SESSION_FILE, json, { mode: 0o600 });
|
|
154
|
+
// Per-port file: lets parallel fleets coexist without clobbering each other.
|
|
155
|
+
if (payload.port) {
|
|
156
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
|
|
157
|
+
fs.writeFileSync(sessionFileForPort(payload.port), json, { mode: 0o600 });
|
|
158
|
+
}
|
|
97
159
|
return payload;
|
|
98
160
|
}
|
|
99
161
|
|
|
162
|
+
// All currently-recorded runtimes (one per per-port file). Most-recent first.
|
|
163
|
+
function listRuntimeSessions() {
|
|
164
|
+
let files;
|
|
165
|
+
try {
|
|
166
|
+
files = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json'));
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const sessions = files.map((f) => {
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'));
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}).filter(Boolean);
|
|
177
|
+
sessions.sort((a, b) => String(b.updatedAt || b.createdAt || '').localeCompare(String(a.updatedAt || a.createdAt || '')));
|
|
178
|
+
return sessions;
|
|
179
|
+
}
|
|
180
|
+
|
|
100
181
|
function updateRuntimeSession(patch) {
|
|
101
182
|
const current = readRuntimeSession() || {};
|
|
102
183
|
return writeRuntimeSession({ ...current, ...patch, updatedAt: new Date().toISOString() });
|
|
@@ -111,7 +192,29 @@ function readRuntimeSession() {
|
|
|
111
192
|
}
|
|
112
193
|
}
|
|
113
194
|
|
|
114
|
-
|
|
195
|
+
// Remove a runtime's record on shutdown. With a port, only THIS runtime's
|
|
196
|
+
// per-port file is removed; the shared pointer is repointed to a surviving
|
|
197
|
+
// fleet (sync, best-effort — no health check, this runs in process 'exit')
|
|
198
|
+
// instead of being deleted, so other live fleets stay discoverable. Called
|
|
199
|
+
// with no port it keeps the old behavior (delete the pointer).
|
|
200
|
+
function clearRuntimeSession(port) {
|
|
201
|
+
if (port) {
|
|
202
|
+
try { fs.unlinkSync(sessionFileForPort(port)); } catch { /* already gone */ }
|
|
203
|
+
let pointer = null;
|
|
204
|
+
try { pointer = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch { /* none */ }
|
|
205
|
+
// Only touch the pointer if it was pointing at us.
|
|
206
|
+
if (!pointer || pointer.port === port) {
|
|
207
|
+
const survivors = listRuntimeSessions();
|
|
208
|
+
if (survivors.length) {
|
|
209
|
+
try {
|
|
210
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify(survivors[0], null, 2) + '\n', { mode: 0o600 });
|
|
211
|
+
} catch { /* best effort */ }
|
|
212
|
+
} else {
|
|
213
|
+
try { fs.unlinkSync(SESSION_FILE); } catch { /* already gone */ }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
115
218
|
try {
|
|
116
219
|
fs.unlinkSync(SESSION_FILE);
|
|
117
220
|
} catch {
|
|
@@ -351,6 +454,7 @@ function hasVisualAfter(afterTimestamp, options = {}) {
|
|
|
351
454
|
module.exports = {
|
|
352
455
|
SESSION_DIR,
|
|
353
456
|
SESSION_FILE,
|
|
457
|
+
SESSIONS_DIR,
|
|
354
458
|
TOKEN_FILE,
|
|
355
459
|
LEDGER_FILE,
|
|
356
460
|
VERIFICATION_LEDGER_FILE,
|
|
@@ -358,10 +462,12 @@ module.exports = {
|
|
|
358
462
|
VALID_VISUAL_STAGES,
|
|
359
463
|
findAvailablePort,
|
|
360
464
|
killStaleRuntime,
|
|
465
|
+
reclaimPort,
|
|
361
466
|
writeRuntimeSession,
|
|
362
467
|
updateRuntimeSession,
|
|
363
468
|
readRuntimeSession,
|
|
364
469
|
clearRuntimeSession,
|
|
470
|
+
listRuntimeSessions,
|
|
365
471
|
writeAuthToken,
|
|
366
472
|
readAuthToken,
|
|
367
473
|
healthCheckSession,
|
package/mcp-server.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Also starts HTTP server on port 3300 for browser UI.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
const pkg = require('./package.json');
|
|
10
11
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
11
12
|
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
12
13
|
const {
|
|
@@ -359,7 +360,7 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
359
360
|
app.get('/health', (_req, res) => {
|
|
360
361
|
res.json({
|
|
361
362
|
status: 'ok',
|
|
362
|
-
version:
|
|
363
|
+
version: `${pkg.version}-mcp`,
|
|
363
364
|
terminals: terminals.size,
|
|
364
365
|
mode: 'mcp',
|
|
365
366
|
});
|
|
@@ -510,7 +511,7 @@ app.post('/api/terminals/:id/input', async (req, res) => {
|
|
|
510
511
|
// ── MCP Server Setup ────────────────────────────────────────
|
|
511
512
|
|
|
512
513
|
const mcpServer = new Server(
|
|
513
|
-
{ name: 'ninja-terminals', version:
|
|
514
|
+
{ name: 'ninja-terminals', version: pkg.version },
|
|
514
515
|
{ capabilities: { tools: {} } }
|
|
515
516
|
);
|
|
516
517
|
|
package/ninja-ensure.js
CHANGED
|
@@ -311,11 +311,18 @@ async function main() {
|
|
|
311
311
|
if (health.ok && !fresh && sessionMatchesLaunchConfig(existingSession, requestedCwd, launchConfig)) {
|
|
312
312
|
session = existingSession;
|
|
313
313
|
action = 'reuse';
|
|
314
|
-
} else if (health.ok) {
|
|
315
|
-
|
|
316
|
-
|
|
314
|
+
} else if (health.ok && fresh) {
|
|
315
|
+
// --fresh explicitly asked to start over: only safe to reclaim a runtime
|
|
316
|
+
// that isn't healthy. killStaleRuntime health-gates, so a live fleet is
|
|
317
|
+
// never killed here either — we just start alongside it.
|
|
317
318
|
const killed = await killStaleRuntime(existingSession);
|
|
318
|
-
if (killed) log(`Terminated previous runtime (pid ${existingSession.pid})`);
|
|
319
|
+
if (killed) log(`Terminated dead previous runtime (pid ${existingSession.pid})`);
|
|
320
|
+
} else if (health.ok) {
|
|
321
|
+
// A healthy fleet is running but doesn't match this launch (different
|
|
322
|
+
// project/mode, or older than the reuse window). Do NOT kill it — that
|
|
323
|
+
// would destroy a parallel instance's work. Start a new server alongside;
|
|
324
|
+
// it climbs to the next free port and the two coexist.
|
|
325
|
+
log('A different healthy Ninja fleet is running — starting a new one alongside it (it will use the next free port).');
|
|
319
326
|
}
|
|
320
327
|
}
|
|
321
328
|
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -1033,7 +1033,8 @@ function setupAddTerminal() {
|
|
|
1033
1033
|
if (!btn) return;
|
|
1034
1034
|
|
|
1035
1035
|
// Store last used directory
|
|
1036
|
-
|
|
1036
|
+
// ponytail: empty default → server falls back to its own process.cwd(); never hardcode an author machine path
|
|
1037
|
+
let lastCwd = localStorage.getItem('ninja-last-cwd') || '';
|
|
1037
1038
|
let lastAgentType = localStorage.getItem('ninja-last-agent-type') || 'claude';
|
|
1038
1039
|
|
|
1039
1040
|
if (preset) {
|
package/public/index.html
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
|
|
10
10
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
|
11
11
|
<link rel="stylesheet" href="style.css">
|
|
12
|
+
<!-- PostHog analytics -->
|
|
13
|
+
<script>
|
|
14
|
+
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
|
15
|
+
posthog.init('phc_AmUYDCH793ekSGYpDxfoH8raVceuXjxnPxqF844i7u7A',{api_host:'https://us.i.posthog.com',defaults:'2025-05-24'})
|
|
16
|
+
</script>
|
|
12
17
|
</head>
|
|
13
18
|
<body>
|
|
14
19
|
<!-- Learnings Modal -->
|
package/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const pkg = require('./package.json');
|
|
1
2
|
const express = require('express');
|
|
2
3
|
const http = require('http');
|
|
3
4
|
const { WebSocketServer } = require('ws');
|
|
@@ -27,6 +28,7 @@ const {
|
|
|
27
28
|
SESSION_FILE,
|
|
28
29
|
findAvailablePort,
|
|
29
30
|
killStaleRuntime,
|
|
31
|
+
reclaimPort,
|
|
30
32
|
readRuntimeSession,
|
|
31
33
|
writeRuntimeSession,
|
|
32
34
|
updateRuntimeSession,
|
|
@@ -181,8 +183,10 @@ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro', agentType =
|
|
|
181
183
|
// Resolve working directory — custom cwd or default to PROJECT_DIR
|
|
182
184
|
// Validate cwd to prevent broken paths like "\" from corrupting terminal startup
|
|
183
185
|
let workDir = cwd || PROJECT_DIR;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
// ponytail: must also exist on disk — a hardcoded/stale absolute path passes isAbsolute
|
|
187
|
+
// but kills pty.spawn ("Connection closed"). Fall back instead of dying.
|
|
188
|
+
if (!workDir || workDir.length < 2 || !path.isAbsolute(workDir) || !fs.existsSync(workDir)) {
|
|
189
|
+
console.warn(`[spawn] Invalid or missing cwd "${workDir}", falling back to PROJECT_DIR`);
|
|
186
190
|
workDir = PROJECT_DIR;
|
|
187
191
|
}
|
|
188
192
|
const settingsDir = workDir;
|
|
@@ -498,7 +502,7 @@ app.post('/api/upload', requireAuth, upload.single('file'), (req, res) => {
|
|
|
498
502
|
app.get('/health', (_req, res) => {
|
|
499
503
|
res.json({
|
|
500
504
|
status: 'ok',
|
|
501
|
-
version:
|
|
505
|
+
version: pkg.version,
|
|
502
506
|
terminals: terminals.size,
|
|
503
507
|
sseClients: sse.clientCount,
|
|
504
508
|
uptime: process.uptime(),
|
|
@@ -1307,11 +1311,17 @@ app.post('/api/auth/register', async (req, res) => {
|
|
|
1307
1311
|
// ── Start ───────────────────────────────────────────────────
|
|
1308
1312
|
|
|
1309
1313
|
async function startServer() {
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1314
|
+
// Clean up only DEAD runtimes — never a healthy one. killStaleRuntime and
|
|
1315
|
+
// reclaimPort both health-gate: a runtime that answers /health is a live
|
|
1316
|
+
// parallel fleet (possibly another instance doing real work) and is left
|
|
1317
|
+
// untouched; this server then climbs to the next free port and coexists.
|
|
1318
|
+
// Only dead/hung leftovers get reclaimed (keeps the no-orphan-pileup win).
|
|
1312
1319
|
const previous = readRuntimeSession();
|
|
1313
1320
|
const replaced = await killStaleRuntime(previous);
|
|
1314
|
-
if (replaced) console.log(`[runtime]
|
|
1321
|
+
if (replaced) console.log(`[runtime] cleaned up dead Ninja runtime (pid ${previous.pid})`);
|
|
1322
|
+
|
|
1323
|
+
const reclaimed = await reclaimPort(PREFERRED_PORT);
|
|
1324
|
+
if (reclaimed) console.log(`[runtime] reclaimed port ${PREFERRED_PORT} from ${reclaimed} dead Ninja orphan(s)`);
|
|
1315
1325
|
|
|
1316
1326
|
const selectedPort = await findAvailablePort(PREFERRED_PORT);
|
|
1317
1327
|
if (selectedPort !== PREFERRED_PORT) {
|
|
@@ -1361,7 +1371,7 @@ async function startServer() {
|
|
|
1361
1371
|
process.on('exit', () => {
|
|
1362
1372
|
try {
|
|
1363
1373
|
const addr = server.address();
|
|
1364
|
-
if (addr && addr.port) clearRuntimeSession();
|
|
1374
|
+
if (addr && addr.port) clearRuntimeSession(addr.port);
|
|
1365
1375
|
} catch {
|
|
1366
1376
|
// ignore shutdown cleanup failures
|
|
1367
1377
|
}
|